title: content-orchestration 외부 연동 구현 플랜 (L2) date: 2026-02-25T22:50:00+09:00 type: implementation-plan layer: L2 status: draft tags: [content-orchestration, external, brevo, channels, L2] author: uiux-impl-pl project: content-orchestration reviewed_by: "jarvis" reviewed_at: "2026-02-25T23:10:00+09:00" approved_by: "" approved_at: ""
content-orchestration 외부 연동 구현 플랜 (L2)
For Claude: REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
Goal: approved 콘텐츠가 channels 테이블 기반으로 블로그 + Brevo 뉴스레터에 자동 배포되고, content_distributions에 배포 결과가 기록되며, SNS는 mock 함수만 준비한다.
Architecture: 기존 stage-publish.ts를 channels 테이블 기반 다채널 배포로 확장한다. src/lib/channels.ts로 채널 조회 유틸리티를 추출하고, src/lib/brevo.ts에 예약 발송(scheduledAt) 기능을 추가한다. 기존 블로그 발행 로직은 유지하되, content_distributions 기록 패턴을 통일한다. SNS(getlate)는 Phase 2이므로 mock 함수만 준비한다.
Tech Stack: Next.js 15 (App Router), TypeScript, @libsql/client/web (Turso), @getbrevo/brevo
근거 문서:
- L1 외부 연동 설계서:
content-orchestration-design-external.md(approved by musk-vp) - L2 Phase 1 백엔드 구현 플랜:
content-orchestration-impl-phase1.md(approved) - 기존 코드:
src/lib/brevo.ts,src/lib/getlate.ts,src/pipeline/stage-publish.ts,src/pipeline/publish-sns.ts
프로젝트 경로: /Users/nbs22/(Claude)/(claude).projects/business-builder/projects/content-pipeline/
GitHub: migkjy/ai-blog
기존 코드 보호 규칙 (절대 준수)
이하 파일은 절대 수정 금지:
src/app/page.tsx(블로그 홈)src/app/posts/[slug]/page.tsx(포스트 페이지)src/app/api/pipeline/content/route.ts(기존 콘텐츠 목록 API)src/app/api/pipeline/approve/route.ts(기존 승인 API — stage-publish 호출 부분)src/app/api/pipeline/reject/route.ts(기존 거부 API)src/pipeline/publish-blog.ts(CLI용 블로그 발행 — 레거시, 건드리지 않음)src/pipeline/publish-sns.ts(CLI용 SNS 발행 — 레거시, 건드리지 않음)src/lib/getlate.ts(기존 getlate 클라이언트 — 변경 없음, Phase 2에서 확장)
수정 허용 파일:
src/lib/brevo.ts—sendCampaignScheduled()함수 추가 (기존 함수 수정 금지, 추가만)src/pipeline/stage-publish.ts— channels 기반 다채널 배포로 확장
전체 구현 범위 요약
| # | Task | 산출물 | 의존성 |
|---|---|---|---|
| 1 | channels 조회 유틸리티 | src/lib/channels.ts | 없음 |
| 2 | Brevo 예약 발송 함수 추가 | src/lib/brevo.ts에 함수 추가 | 없음 |
| 3 | SNS mock 배포 함수 | src/lib/sns-mock.ts | 없음 |
| 4 | 통합 배포 오케스트레이터 | src/lib/publish-orchestrator.ts | Task 1, 2, 3 |
| 5 | stage-publish 리팩터링 | src/pipeline/stage-publish.ts 수정 | Task 4 |
| 6 | E2E 수동 테스트 + 빌드 확인 | 빌드 성공 | Task 1-5 |
Task 1: channels 조회 유틸리티
Files:
- Create:
src/lib/channels.ts
Step 1: Implement channels utility
src/lib/channels.ts:
import { createClient } from '@libsql/client/web';
function getContentDb() {
return createClient({
url: process.env.CONTENT_OS_DB_URL!,
authToken: process.env.CONTENT_OS_DB_TOKEN!,
});
}
export interface Channel {
id: string;
name: string;
type: string; // 'blog' | 'newsletter' | 'sns'
platform: string; // 'apppro.kr' | 'brevo' | 'twitter' | 'linkedin'
project: string | null;
config: string | null;
credentials_ref: string | null;
is_active: number;
created_at: number;
updated_at: number;
}
export interface ChannelConfig {
[key: string]: unknown;
}
/**
* 활성 채널 목록 조회.
* is_active=1인 채널만 반환.
* type으로 필터 가능 (예: 'blog', 'newsletter', 'sns').
*/
export async function getActiveChannels(type?: string): Promise<Channel[]> {
const db = getContentDb();
let sql = 'SELECT * FROM channels WHERE is_active = 1';
const args: (string | number)[] = [];
if (type) {
sql += ' AND type = ?';
args.push(type);
}
sql += ' ORDER BY created_at ASC';
const result = await db.execute({ sql, args });
return result.rows as unknown as Channel[];
}
/**
* 채널 ID로 조회.
*/
export async function getChannelById(id: string): Promise<Channel | null> {
const db = getContentDb();
const result = await db.execute({
sql: 'SELECT * FROM channels WHERE id = ? LIMIT 1',
args: [id],
});
return result.rows.length > 0 ? (result.rows[0] as unknown as Channel) : null;
}
/**
* 채널의 config JSON을 파싱.
* 파싱 실패 시 빈 객체 반환.
*/
export function parseChannelConfig(channel: Channel): ChannelConfig {
if (!channel.config) return {};
try {
return JSON.parse(channel.config);
} catch {
return {};
}
}
/**
* 채널의 credentials_ref에 대응하는 환경변수 값을 반환.
* 미설정 시 null (mock 모드 진입용).
*/
export function getChannelCredential(channel: Channel): string | null {
if (!channel.credentials_ref) return null;
return process.env[channel.credentials_ref] || null;
}
Step 2: Verify build
Run: cd /Users/nbs22/'(Claude)'/'(claude)'.projects/business-builder/projects/content-pipeline && npx tsc --noEmit --pretty 2>&1 | head -20
Expected: No errors for the new file
Step 3: Commit
cd /Users/nbs22/'(Claude)'/'(claude)'.projects/business-builder/projects/content-pipeline
git add src/lib/channels.ts
git commit -m "feat(pipeline): add channels utility — getActiveChannels, getChannelById, parseConfig, getCredential"
Task 2: Brevo 예약 발송 함수 추가
Files:
- Modify:
src/lib/brevo.ts(기존 함수 수정 금지, 새 함수 추가만)
Step 1: Add sendCampaignScheduled and getCampaignStatus functions
파일 맨 아래에 아래 코드를 추가한다. 기존 함수는 절대 수정하지 않는다.
src/lib/brevo.ts 맨 아래에 추가:
// ============================================================
// Phase 1: 예약 발송 + 캠페인 상태 조회 (content-orchestration)
// ============================================================
export interface SendCampaignScheduledResult {
success: boolean;
mock: boolean;
campaignId?: number;
scheduledAt?: string;
error?: string;
}
/**
* 이메일 캠페인 생성 + 예약 발송 (scheduledAt 지원)
*
* 기존 sendCampaign()은 즉시 발송만 지원.
* 이 함수는 scheduledAt이 있으면 예약, 없으면 즉시 발송.
*
* @param listId 발송 대상 리스트 ID
* @param subject 이메일 제목
* @param htmlContent HTML 본문
* @param scheduledAt 예약 시각 (ISO 8601, 예: "2026-02-26T10:00:00+09:00"). null이면 즉시 발송.
*/
export async function sendCampaignScheduled(
listId: number,
subject: string,
htmlContent: string,
scheduledAt?: string | null,
): Promise<SendCampaignScheduledResult> {
if (isMockMode()) {
console.log(`[brevo:mock] sendCampaignScheduled — mock 모드`);
console.log(` listId: ${listId}, subject: ${subject}`);
console.log(` scheduledAt: ${scheduledAt ?? '즉시 발송'}`);
return { success: false, mock: true };
}
try {
const client = getClient();
const senderName = process.env.BREVO_SENDER_NAME || "AI AppPro";
const senderEmail = process.env.BREVO_SENDER_EMAIL || "hello@apppro.kr";
const campaignName = `[AI AppPro] ${subject} — ${new Date().toISOString().split("T")[0]}`;
// 캠페인 생성 파라미터
const createParams: Record<string, unknown> = {
name: campaignName,
subject,
htmlContent,
sender: { name: senderName, email: senderEmail },
recipients: { listIds: [listId] },
};
if (scheduledAt) {
createParams.scheduledAt = scheduledAt;
}
// 캠페인 생성
const createResponse = await client.emailCampaigns.createEmailCampaign(createParams);
const campaignId = (createResponse as unknown as { id?: number }).id;
if (!campaignId) {
return { success: false, mock: false, error: "캠페인 ID 없음" };
}
console.log(`[brevo] 캠페인 생성 완료 (id: ${campaignId})`);
// scheduledAt 없으면 즉시 발송
if (!scheduledAt) {
await client.emailCampaigns.sendEmailCampaignNow({ campaignId });
console.log(`[brevo] 즉시 발송 완료. Campaign ID: ${campaignId}`);
} else {
console.log(`[brevo] 예약 발송 설정 완료: ${scheduledAt}. Campaign ID: ${campaignId}`);
}
return { success: true, mock: false, campaignId, scheduledAt: scheduledAt ?? undefined };
} catch (err: unknown) {
const message = err instanceof Error ? err.message : String(err);
console.error(`[brevo] sendCampaignScheduled 오류: ${message}`);
return { success: false, mock: false, error: message };
}
}
export interface CampaignStatusResult {
success: boolean;
campaignId: number;
status: string | null;
delivered?: number;
opens?: number;
clicks?: number;
bounces?: number;
error?: string;
}
/**
* 캠페인 상태 조회 (발송 결과 확인용).
* Phase 1에서는 수동 확인용. Phase 2에서 자동 폴링 적용.
*/
export async function getCampaignStatus(campaignId: number): Promise<CampaignStatusResult> {
if (isMockMode()) {
return { success: false, campaignId, status: null, error: 'MOCK_MODE' };
}
try {
const client = getClient();
const response = await client.emailCampaigns.getEmailCampaign({ campaignId });
const data = response as unknown as {
status?: string;
statistics?: {
globalStats?: {
delivered?: number;
opens?: number;
clicks?: number;
bounces?: number;
};
};
};
return {
success: true,
campaignId,
status: data.status ?? null,
delivered: data.statistics?.globalStats?.delivered,
opens: data.statistics?.globalStats?.opens,
clicks: data.statistics?.globalStats?.clicks,
bounces: data.statistics?.globalStats?.bounces,
};
} catch (err: unknown) {
const message = err instanceof Error ? err.message : String(err);
return { success: false, campaignId, status: null, error: message };
}
}
Step 2: Verify build
Run: cd /Users/nbs22/'(Claude)'/'(claude)'.projects/business-builder/projects/content-pipeline && npx tsc --noEmit --pretty 2>&1 | head -20
Expected: No errors
Step 3: Commit
cd /Users/nbs22/'(Claude)'/'(claude)'.projects/business-builder/projects/content-pipeline
git add src/lib/brevo.ts
git commit -m "feat(brevo): add sendCampaignScheduled and getCampaignStatus for Phase 1"
Task 3: SNS mock 배포 함수
Files:
- Create:
src/lib/sns-mock.ts
Phase 1에서 SNS(getlate)는 실제 발송하지 않는다. mock 함수를 준비하여 publish-orchestrator에서 호출 가능한 인터페이스를 확보한다. Phase 2에서 이 파일을 src/lib/getlate.ts 연동으로 교체한다.
Step 1: Implement SNS mock module
src/lib/sns-mock.ts:
/**
* SNS 배포 mock 모듈 (Phase 1).
*
* Phase 1에서는 SNS 채널이 is_active=0이므로 이 함수가 호출되지 않지만,
* 만약 호출되더라도 안전하게 mock 결과를 반환한다.
* Phase 2에서 실제 getlate.dev 연동으로 교체 예정.
*/
export interface SnsPublishResult {
success: boolean;
mock: boolean;
channelId: string;
platformId: string | null;
error: string | null;
}
/**
* SNS 채널에 콘텐츠 배포 (Phase 1 mock).
*
* 항상 mock 결과를 반환한다. content_distributions에는
* platform_status='failed', error_message='MOCK_MODE: Phase 2' 로 기록.
*/
export async function publishToSnsMock(
channelId: string,
_contentId: string,
_title: string,
_contentBody: string,
): Promise<SnsPublishResult> {
console.log(`[sns-mock] SNS 배포 mock: channelId=${channelId} — Phase 2에서 구현 예정`);
return {
success: false,
mock: true,
channelId,
platformId: null,
error: 'MOCK_MODE: SNS publishing is Phase 2',
};
}
Step 2: Commit
cd /Users/nbs22/'(Claude)'/'(claude)'.projects/business-builder/projects/content-pipeline
git add src/lib/sns-mock.ts
git commit -m "feat(pipeline): add SNS mock publisher for Phase 1 (Phase 2 placeholder)"
Task 4: 통합 배포 오케스트레이터
Files:
- Create:
src/lib/publish-orchestrator.ts
Depends on: Task 1 (channels), Task 2 (brevo scheduled), Task 3 (sns-mock)
이 모듈이 channels 테이블을 조회하여 활성 채널별로 적절한 배포 함수를 호출하고, content_distributions에 결과를 기록한다.
Step 1: Implement publish orchestrator
src/lib/publish-orchestrator.ts:
import { createClient } from '@libsql/client/web';
import { getActiveChannels, parseChannelConfig, getChannelCredential, type Channel } from './channels';
import { sendCampaignScheduled } from './brevo';
import { publishToSnsMock } from './sns-mock';
import { logError } from './pipeline-logger';
function getContentDb() {
return createClient({
url: process.env.CONTENT_OS_DB_URL!,
authToken: process.env.CONTENT_OS_DB_TOKEN!,
});
}
function getBlogDb() {
return createClient({
url: process.env.TURSO_DB_URL!,
authToken: process.env.TURSO_DB_TOKEN!,
});
}
export interface ChannelPublishResult {
channelId: string;
channelName: string;
type: string;
success: boolean;
mock: boolean;
platformId: string | null;
platformUrl: string | null;
distributionId: string | null;
error: string | null;
}
export interface OrchestratorResult {
contentId: string;
totalChannels: number;
successCount: number;
failCount: number;
channels: ChannelPublishResult[];
}
/**
* content_distributions에 배포 레코드 INSERT.
*/
async function insertDistribution(
contentId: string,
channelId: string,
platformStatus: string,
platformId: string | null,
platformUrl: string | null,
errorMessage: string | null,
scheduledAt: number | null,
publishedAt: number | null,
): Promise<string> {
const db = getContentDb();
const id = crypto.randomUUID();
const now = Date.now();
await db.execute({
sql: `INSERT INTO content_distributions
(id, content_id, channel_id, platform_status, platform_id, platform_url,
scheduled_at, published_at, error_message, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
args: [id, contentId, channelId, platformStatus, platformId, platformUrl,
scheduledAt, publishedAt, errorMessage, now, now],
});
return id;
}
/**
* 블로그(apppro.kr) 채널 배포.
* 기존 stage-publish.ts의 publishToBlog 로직을 활용.
*/
async function publishToBlogChannel(
channel: Channel,
contentId: string,
title: string,
contentBody: string,
): Promise<ChannelPublishResult> {
const credential = getChannelCredential(channel);
if (!credential) {
const distId = await insertDistribution(
contentId, channel.id, 'failed', null, null,
`MOCK_MODE: ${channel.credentials_ref} not set`, null, null,
);
return {
channelId: channel.id, channelName: channel.name, type: channel.type,
success: false, mock: true, platformId: null, platformUrl: null,
distributionId: distId, error: `MOCK_MODE: ${channel.credentials_ref} not set`,
};
}
try {
// content_body를 JSON으로 파싱 (generate 단계에서 JSON 형식으로 저장)
let parsed: {
content: string; slug: string; excerpt: string;
meta_description: string; category: string; tags: string[];
};
try {
parsed = JSON.parse(contentBody);
} catch {
throw new Error('content_body JSON 파싱 실패');
}
const blogDb = getBlogDb();
// slug 중복 체크
const existing = await blogDb.execute({
sql: 'SELECT id FROM blog_posts WHERE slug = ?',
args: [parsed.slug],
});
let finalSlug = parsed.slug;
if (existing.rows.length > 0) {
const dateSuffix = new Date().toISOString().split('T')[0];
finalSlug = `${parsed.slug}-${dateSuffix}`;
console.log(`[orchestrator] slug 중복, 변경: ${parsed.slug} -> ${finalSlug}`);
}
const postId = crypto.randomUUID();
const now = Date.now();
await blogDb.execute({
sql: `INSERT INTO blog_posts (
id, title, slug, content, excerpt, category, tags,
author, published, publishedAt, metaDescription, createdAt, updatedAt
) VALUES (?, ?, ?, ?, ?, ?, ?, 'AI AppPro', 1, ?, ?, ?, ?)`,
args: [
postId, title, finalSlug, parsed.content, parsed.excerpt,
parsed.category, JSON.stringify(parsed.tags), now,
parsed.meta_description, now, now,
],
});
const platformUrl = `https://apppro.kr/blog/posts/${finalSlug}`;
console.log(`[orchestrator] 블로그 발행 완료: ${platformUrl}`);
// content_distributions 기록
const distId = await insertDistribution(
contentId, channel.id, 'published', postId, platformUrl, null, null, now,
);
// content_logs (불변 감사 로그)
const contentDb = getContentDb();
await contentDb.execute({
sql: `INSERT INTO content_logs (id, content_type, content_id, title, platform, status, published_at)
VALUES (?, 'blog', ?, ?, 'apppro.kr', 'published', ?)`,
args: [crypto.randomUUID(), contentId, title, now],
});
return {
channelId: channel.id, channelName: channel.name, type: channel.type,
success: true, mock: false, platformId: postId, platformUrl,
distributionId: distId, error: null,
};
} catch (err) {
const errMsg = err instanceof Error ? err.message : String(err);
const errLogId = await logError('publisher', 'api_error', errMsg, {
contentId, channelId: channel.id,
});
const distId = await insertDistribution(
contentId, channel.id, 'failed', null, null, errMsg, null, null,
);
console.error(`[orchestrator] 블로그 발행 실패: ${errMsg} (errorLogId: ${errLogId})`);
return {
channelId: channel.id, channelName: channel.name, type: channel.type,
success: false, mock: false, platformId: null, platformUrl: null,
distributionId: distId, error: errMsg,
};
}
}
/**
* Brevo 뉴스레터 채널 배포.
*/
async function publishToBrevoChannel(
channel: Channel,
contentId: string,
title: string,
contentBody: string,
): Promise<ChannelPublishResult> {
const credential = getChannelCredential(channel);
if (!credential) {
const distId = await insertDistribution(
contentId, channel.id, 'failed', null, null,
`MOCK_MODE: ${channel.credentials_ref} not set`, null, null,
);
return {
channelId: channel.id, channelName: channel.name, type: channel.type,
success: false, mock: true, platformId: null, platformUrl: null,
distributionId: distId, error: `MOCK_MODE: ${channel.credentials_ref} not set`,
};
}
try {
const config = parseChannelConfig(channel);
const listId = (config.list_id as number) || parseInt(process.env.BREVO_LIST_ID || '0', 10);
if (!listId) {
throw new Error('Brevo list_id 미설정 (channels.config.list_id 또는 BREVO_LIST_ID)');
}
// content_body에서 HTML 생성 (간단 마크다운 → HTML 변환)
let htmlContent: string;
try {
const parsed = JSON.parse(contentBody);
// content 필드가 마크다운이면 간단 HTML 래핑
htmlContent = `<html><body>
<h1>${title}</h1>
<div>${(parsed.content as string || contentBody).replace(/\n/g, '<br/>')}</div>
<hr/>
<p><a href="https://apppro.kr/blog">AI AppPro 블로그에서 더 보기</a></p>
</body></html>`;
} catch {
htmlContent = `<html><body>
<h1>${title}</h1>
<div>${contentBody.replace(/\n/g, '<br/>')}</div>
</body></html>`;
}
const result = await sendCampaignScheduled(listId, title, htmlContent, null);
if (!result.success) {
if (result.mock) {
const distId = await insertDistribution(
contentId, channel.id, 'failed', null, null, 'MOCK_MODE', null, null,
);
return {
channelId: channel.id, channelName: channel.name, type: channel.type,
success: false, mock: true, platformId: null, platformUrl: null,
distributionId: distId, error: 'MOCK_MODE',
};
}
throw new Error(result.error || 'Brevo 캠페인 생성 실패');
}
const campaignId = result.campaignId!;
const now = Date.now();
// content_distributions — 발송 완료
const distId = await insertDistribution(
contentId, channel.id, 'published',
String(campaignId), null, null, null, now,
);
console.log(`[orchestrator] Brevo 캠페인 발송 완료: campaignId=${campaignId}`);
return {
channelId: channel.id, channelName: channel.name, type: channel.type,
success: true, mock: false, platformId: String(campaignId), platformUrl: null,
distributionId: distId, error: null,
};
} catch (err) {
const errMsg = err instanceof Error ? err.message : String(err);
// 인증 실패면 에스컬레이션
const isAuthFail = errMsg.includes('401') || errMsg.toLowerCase().includes('unauthorized');
const errorType = isAuthFail ? 'auth_fail' as const : 'api_error' as const;
const errLogId = await logError('brevo', errorType, errMsg, {
contentId, channelId: channel.id,
});
// 인증 실패 시 에스컬레이션
if (isAuthFail) {
const contentDb = getContentDb();
await contentDb.execute({
sql: 'UPDATE error_logs SET escalated = 1 WHERE id = ?',
args: [errLogId],
});
}
const distId = await insertDistribution(
contentId, channel.id, 'failed', null, null, errMsg, null, null,
);
console.error(`[orchestrator] Brevo 발송 실패: ${errMsg} (errorLogId: ${errLogId})`);
return {
channelId: channel.id, channelName: channel.name, type: channel.type,
success: false, mock: false, platformId: null, platformUrl: null,
distributionId: distId, error: errMsg,
};
}
}
/**
* SNS 채널 배포 (Phase 1 mock).
*/
async function publishToSnsChannel(
channel: Channel,
contentId: string,
title: string,
contentBody: string,
): Promise<ChannelPublishResult> {
const result = await publishToSnsMock(channel.id, contentId, title, contentBody);
const distId = await insertDistribution(
contentId, channel.id, 'failed', null, null,
result.error, null, null,
);
return {
channelId: channel.id, channelName: channel.name, type: channel.type,
success: false, mock: true, platformId: null, platformUrl: null,
distributionId: distId, error: result.error,
};
}
/**
* 통합 배포 오케스트레이터.
*
* channels 테이블에서 활성 채널 목록을 조회하고,
* 채널 type별로 적절한 배포 함수를 호출한다.
* 모든 결과를 content_distributions에 기록한다.
*
* @param contentId content_queue.id
* @param title 콘텐츠 제목
* @param contentBody 콘텐츠 본문 (JSON 형식)
*/
export async function publishToAllChannels(
contentId: string,
title: string,
contentBody: string,
): Promise<OrchestratorResult> {
const channels = await getActiveChannels();
console.log(`[orchestrator] 활성 채널 ${channels.length}개: ${channels.map(c => `${c.name}(${c.type})`).join(', ')}`);
const results: ChannelPublishResult[] = [];
for (const channel of channels) {
let result: ChannelPublishResult;
switch (channel.type) {
case 'blog':
result = await publishToBlogChannel(channel, contentId, title, contentBody);
break;
case 'newsletter':
result = await publishToBrevoChannel(channel, contentId, title, contentBody);
break;
case 'sns':
result = await publishToSnsChannel(channel, contentId, title, contentBody);
break;
default:
console.warn(`[orchestrator] 알 수 없는 채널 type: ${channel.type}`);
result = {
channelId: channel.id, channelName: channel.name, type: channel.type,
success: false, mock: false, platformId: null, platformUrl: null,
distributionId: null, error: `Unknown channel type: ${channel.type}`,
};
}
results.push(result);
}
const successCount = results.filter(r => r.success).length;
const failCount = results.filter(r => !r.success && !r.mock).length;
return {
contentId,
totalChannels: channels.length,
successCount,
failCount,
channels: results,
};
}
Step 2: Verify build
Run: cd /Users/nbs22/'(Claude)'/'(claude)'.projects/business-builder/projects/content-pipeline && npx tsc --noEmit --pretty 2>&1 | head -20
Expected: No errors
Step 3: Commit
cd /Users/nbs22/'(Claude)'/'(claude)'.projects/business-builder/projects/content-pipeline
git add src/lib/publish-orchestrator.ts
git commit -m "feat(pipeline): add multi-channel publish orchestrator — blog + brevo + sns-mock"
Task 5: stage-publish 리팩터링 — channels 기반 다채널 배포
Files:
- Modify:
src/pipeline/stage-publish.ts
Depends on: Task 4
기존 stage-publish.ts를 수정하여, 하드코딩된 블로그 발행 대신 publishToAllChannels를 호출한다.
기존 publishToBlog, createDistribution, getNextApproved 내부 함수는 유지하되, runPublishStage에서 오케스트레이터를 사용하도록 변경한다.
Step 1: Update runPublishStage to use orchestrator
src/pipeline/stage-publish.ts 전체를 아래로 교체:
// projects/content-pipeline/src/pipeline/stage-publish.ts
import { createClient } from '@libsql/client/web';
import {
logPipelineStart,
logPipelineComplete,
logPipelineFailed,
logError,
logAutoFix,
type TriggerType,
} from '../lib/pipeline-logger';
import { publishToAllChannels } from '../lib/publish-orchestrator';
function getContentDb() {
return createClient({
url: process.env.CONTENT_OS_DB_URL!,
authToken: process.env.CONTENT_OS_DB_TOKEN!,
});
}
export interface PublishResult {
success: boolean;
contentId: string;
blogPostId: string | null;
distributionId: string | null;
pipelineLogId: string;
}
/**
* content_queue에서 approved 아이템 1건 가져오기
*/
async function getNextApproved(): Promise<{
id: string;
title: string;
contentBody: string;
pillar: string | null;
} | null> {
const db = getContentDb();
const result = await db.execute({
sql: `SELECT id, title, content_body, pillar
FROM content_queue
WHERE status = 'approved'
ORDER BY approved_at ASC
LIMIT 1`,
args: [],
});
if (result.rows.length === 0) return null;
const row = result.rows[0];
return {
id: row.id as string,
title: row.title as string,
contentBody: row.content_body as string,
pillar: row.pillar as string | null,
};
}
/**
* content_queue status 업데이트
*/
async function updateContentQueueStatus(contentId: string, status: string): Promise<void> {
const db = getContentDb();
await db.execute({
sql: 'UPDATE content_queue SET status = ?, updated_at = ? WHERE id = ?',
args: [status, Date.now(), contentId],
});
}
/**
* Stage 4: approved -> 다채널 배포 (channels 테이블 기반)
*
* Phase 1: 블로그(apppro.kr) + Brevo 뉴스레터. SNS는 mock.
* content_distributions에 채널별 배포 결과 기록.
*/
export async function runPublishStage(
contentId?: string,
triggerType: TriggerType = 'scheduled'
): Promise<PublishResult> {
const pipelineLog = await logPipelineStart('publish', triggerType);
try {
// approved 아이템 가져오기
let item: { id: string; title: string; contentBody: string; pillar: string | null } | null;
if (contentId) {
const db = getContentDb();
const result = await db.execute({
sql: 'SELECT id, title, content_body, pillar FROM content_queue WHERE id = ? AND status = ?',
args: [contentId, 'approved'],
});
if (result.rows.length === 0) {
await logPipelineFailed(pipelineLog.id, `contentId ${contentId}가 approved 상태가 아닙니다`);
return { success: false, contentId: contentId || '', blogPostId: null, distributionId: null, pipelineLogId: pipelineLog.id };
}
const row = result.rows[0];
item = { id: row.id as string, title: row.title as string, contentBody: row.content_body as string, pillar: row.pillar as string | null };
} else {
item = await getNextApproved();
}
if (!item) {
console.log('[stage-publish] 발행 대기 콘텐츠 없음');
await logPipelineComplete(pipelineLog.id, 0, { message: 'no_approved_content' });
return { success: true, contentId: '', blogPostId: null, distributionId: null, pipelineLogId: pipelineLog.id };
}
console.log(`[stage-publish] 발행 대상: ${item.id} "${item.title}"`);
// 다채널 배포 오케스트레이터 호출
const orchResult = await publishToAllChannels(item.id, item.title, item.contentBody);
// 블로그 채널 결과 추출 (approve API 호환용)
const blogChannel = orchResult.channels.find(c => c.type === 'blog' && c.success);
const blogPostId = blogChannel?.platformId ?? null;
const blogDistId = blogChannel?.distributionId ?? null;
// 성공 여부 판단: 블로그 채널이 성공이면 전체 성공
const hasSuccess = orchResult.successCount > 0;
if (hasSuccess) {
// content_queue status -> published
await updateContentQueueStatus(item.id, 'published');
await logPipelineComplete(pipelineLog.id, orchResult.successCount, {
channels_ok: orchResult.successCount,
channels_fail: orchResult.failCount,
channels: orchResult.channels.map(c => ({
name: c.channelName,
type: c.type,
success: c.success,
mock: c.mock,
platformId: c.platformId,
})),
blog_post_id: blogPostId,
});
console.log(`[stage-publish] 완료: ${item.id} -> published (${orchResult.successCount}/${orchResult.totalChannels} channels)`);
} else {
// 모든 채널 실패 시
// mock만 실패인 경우(채널이 모두 mock)는 에러가 아님
const allMock = orchResult.channels.every(c => c.mock);
if (allMock) {
// 모두 mock 모드 — 블로그 DB 자격 증명 미설정 등
await updateContentQueueStatus(item.id, 'failed');
await logPipelineComplete(pipelineLog.id, 0, {
channels_ok: 0,
channels_fail: 0,
channels_mock: orchResult.totalChannels,
message: 'all_channels_mock_mode',
});
} else {
// 실제 실패
await updateContentQueueStatus(item.id, 'failed');
const firstError = orchResult.channels.find(c => !c.success && !c.mock);
const errId = await logError('publisher', 'api_error',
`다채널 배포 전부 실패: ${firstError?.error || 'unknown'}`,
{ contentId: item.id },
);
await logPipelineFailed(pipelineLog.id,
`모든 채널 배포 실패 (${orchResult.failCount}/${orchResult.totalChannels})`, errId,
);
}
}
return {
success: hasSuccess,
contentId: item.id,
blogPostId,
distributionId: blogDistId,
pipelineLogId: pipelineLog.id,
};
} catch (err) {
const errMsg = err instanceof Error ? err.message : String(err);
const errorLogId = await logError('publisher', 'api_error', errMsg);
await logPipelineFailed(pipelineLog.id, errMsg, errorLogId);
return { success: false, contentId: contentId || '', blogPostId: null, distributionId: null, pipelineLogId: pipelineLog.id };
}
}
Step 2: Verify build
Run: cd /Users/nbs22/'(Claude)'/'(claude)'.projects/business-builder/projects/content-pipeline && npx tsc --noEmit --pretty 2>&1 | head -20
Expected: No errors. PublishResult interface is unchanged, so approve/route.ts that imports it continues to work.
Step 3: Verify approve API import still works
Run: cd /Users/nbs22/'(Claude)'/'(claude)'.projects/business-builder/projects/content-pipeline && npx tsc --noEmit src/app/api/pipeline/approve/route.ts 2>&1
Expected: No errors (approve route imports runPublishStage which still exports the same interface)
Step 4: Commit
cd /Users/nbs22/'(Claude)'/'(claude)'.projects/business-builder/projects/content-pipeline
git add src/pipeline/stage-publish.ts
git commit -m "refactor(stage-publish): channels-based multi-channel publish via orchestrator"
Task 6: E2E 수동 테스트 + 빌드 확인
Depends on: Task 1-5 전부 완료
Step 1: Run full build
Run: cd /Users/nbs22/'(Claude)'/'(claude)'.projects/business-builder/projects/content-pipeline && npm run build 2>&1 | tail -20
Expected: Build completed (no errors)
Step 2: Verify protected files are untouched
Run: cd /Users/nbs22/'(Claude)'/'(claude)'.projects/business-builder/projects/content-pipeline && git diff --name-only src/app/page.tsx src/app/posts/ src/app/api/pipeline/content/route.ts src/app/api/pipeline/approve/route.ts src/app/api/pipeline/reject/route.ts src/lib/getlate.ts src/pipeline/publish-blog.ts src/pipeline/publish-sns.ts
Expected: No output (none of these protected files were changed)
Step 3: Verify all new/modified files exist
Run:
cd /Users/nbs22/'(Claude)'/'(claude)'.projects/business-builder/projects/content-pipeline
ls -la src/lib/channels.ts \
src/lib/sns-mock.ts \
src/lib/publish-orchestrator.ts \
src/lib/brevo.ts \
src/pipeline/stage-publish.ts
Expected: All 5 files listed
Step 4: Verify brevo.ts has new functions appended (not original functions removed)
Run: cd /Users/nbs22/'(Claude)'/'(claude)'.projects/business-builder/projects/content-pipeline && grep -c "export async function" src/lib/brevo.ts
Expected: 5 or more (original: addContact, createList, sendCampaign + new: sendCampaignScheduled, getCampaignStatus)
Step 5: Final push
cd /Users/nbs22/'(Claude)'/'(claude)'.projects/business-builder/projects/content-pipeline
git pull --rebase origin main && git push origin main
파일 목록 총 정리
신규 파일 (3개)
| # | 파일 | 기능 |
|---|---|---|
| 1 | src/lib/channels.ts | channels 테이블 조회 (getActiveChannels, getChannelById, parseConfig, getCredential) |
| 2 | src/lib/sns-mock.ts | SNS mock 배포 (Phase 2 placeholder) |
| 3 | src/lib/publish-orchestrator.ts | 통합 다채널 배포 오케스트레이터 (blog + brevo + sns 분기) |
수정 파일 (2개)
| # | 파일 | 변경 내용 |
|---|---|---|
| 1 | src/lib/brevo.ts | sendCampaignScheduled() + getCampaignStatus() 함수 추가 (기존 함수 변경 없음) |
| 2 | src/pipeline/stage-publish.ts | runPublishStage()를 channels 기반 오케스트레이터 호출로 변경 (PublishResult 인터페이스 동일 유지) |
변경 없는 파일 (기존 코드 보호)
src/app/api/pipeline/approve/route.ts—runPublishStage임포트 그대로 동작src/lib/getlate.ts— Phase 2에서 확장 예정src/pipeline/publish-blog.ts— CLI용 레거시 (변경 없음)src/pipeline/publish-sns.ts— CLI용 레거시 (변경 없음)- 블로그 프론트엔드 전체 (page.tsx, posts/, feed.xml 등)
핵심 설계 결정
- PublishResult 인터페이스 동일 유지: approve/route.ts의
runPublishStage()호출이 깨지지 않음 - channels 테이블 기반: 채널 추가/비활성화를 DB 조작만으로 가능
- mock 모드 통일: credentials_ref에 대응하는 env var가 없으면 자동 mock
- Brevo 기존 함수 보존: sendCampaign()은 그대로, 새 함수만 추가
- SNS Phase 2 대비: sns-mock.ts 인터페이스를 Phase 2에서 getlate 연동으로 교체
리뷰 로그
[자비스 검수] 2026-02-25 23:10
✅ L1 외부 연동 설계서(content-orchestration-design-external.md) 반영 확인 — channels 기반 동적 채널 관리, 3채널(blog/newsletter/sns) 분기 구조 일치 ✅ 기존 코드 보호 — brevo.ts 기존 함수(addContact, createList, sendCampaign) 수정 없음, 새 함수 2개(sendCampaignScheduled, getCampaignStatus) append only ✅ approve/route.ts 호환성 — PublishResult 인터페이스 동일 유지, Task 5 verify step에 tsc 단독 확인 명시 ✅ Mock 모드 설계 — credentials_ref에 대응하는 env var 미설정 시 자동 mock 전환, 개발/CI 환경 안전 보장 ✅ SNS Phase 2 대비 — sns-mock.ts로 인터페이스 확보, Phase 2에서 getlate.ts 교체 경로 명시 ✅ 에러 에스컬레이션 — Brevo 401 인증 실패 → error_logs.escalated=1 자동 설정 (L5 연계) ✅ DB 기록 통일 — insertDistribution() 헬퍼로 content_distributions 기록 패턴 통일 ✅ 의존성 체계 — Task 4 depends on 1+2+3, Task 5 depends on 4 명시 ✅ E2E 검증 — Task 6에서 npm run build + 보호 파일 git diff 확인 + 5개 파일 존재 확인 ⚠️ 주의: Task 5 publishToBlogChannel의 blog_posts INSERT 컬럼명 확인 필요 — apppro-kr DB가 2026-02-23 camelCase→snake_case 전체 수정됨. 코드의 publishedAt / createdAt / updatedAt / metaDescription 컬럼명이 실제 apppro-kr DB 스키마와 일치하는지 실행 전 확인 필요 (불일치 시 INSERT 오류) 검수 결론: 9항목 ✅, 주의 1개. 기존 코드 보호+호환성 우수. ⚠️ 컬럼명 확인 후 VP 승인 요청
[uiux-impl-pl 초안 작성] 2026-02-25 22:50
- L1 외부 연동 설계서 분석 완료: Brevo/블로그/getlate/Telegram 4대 연동 대상, channels 기반 동적 관리
- 기존 코드 리딩 완료: brevo.ts(209줄), getlate.ts(258줄), stage-publish.ts(283줄), publish-blog.ts(92줄), publish-sns.ts(304줄)
- 6개 Task로 분할: channels 유틸(1) + Brevo 확장(1) + SNS mock(1) + 오케스트레이터(1) + stage-publish 리팩터(1) + E2E(1)
- 기존 approve/route.ts의 runPublishStage() 임포트 호환성 유지 (PublishResult 인터페이스 동일)
- Brevo brevo.ts 기존 함수 3개(addContact, createList, sendCampaign) 수정 없음, 새 함수 2개만 추가
- getlate.ts, publish-blog.ts, publish-sns.ts 전부 변경 없음 (CLI 레거시 보호)
- channels.credentials_ref -> process.env 매핑으로 mock 모드 자동 판단
- content_distributions에 채널별 배포 결과 기록 패턴 통일 (pending->registered->published/failed)
- 인증 실패(401) 시 error_logs.escalated=1 자동 설정