← 목록으로
2026-02-25plans

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.tssendCampaignScheduled() 함수 추가 (기존 함수 수정 금지, 추가만)
  • src/pipeline/stage-publish.ts — channels 기반 다채널 배포로 확장

전체 구현 범위 요약

#Task산출물의존성
1channels 조회 유틸리티src/lib/channels.ts없음
2Brevo 예약 발송 함수 추가src/lib/brevo.ts에 함수 추가없음
3SNS mock 배포 함수src/lib/sns-mock.ts없음
4통합 배포 오케스트레이터src/lib/publish-orchestrator.tsTask 1, 2, 3
5stage-publish 리팩터링src/pipeline/stage-publish.ts 수정Task 4
6E2E 수동 테스트 + 빌드 확인빌드 성공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개)

#파일기능
1src/lib/channels.tschannels 테이블 조회 (getActiveChannels, getChannelById, parseConfig, getCredential)
2src/lib/sns-mock.tsSNS mock 배포 (Phase 2 placeholder)
3src/lib/publish-orchestrator.ts통합 다채널 배포 오케스트레이터 (blog + brevo + sns 분기)

수정 파일 (2개)

#파일변경 내용
1src/lib/brevo.tssendCampaignScheduled() + getCampaignStatus() 함수 추가 (기존 함수 변경 없음)
2src/pipeline/stage-publish.tsrunPublishStage()를 channels 기반 오케스트레이터 호출로 변경 (PublishResult 인터페이스 동일 유지)

변경 없는 파일 (기존 코드 보호)

  • src/app/api/pipeline/approve/route.tsrunPublishStage 임포트 그대로 동작
  • 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 자동 설정
plans/2026/02/25/content-orchestration-impl-external.md