← 목록으로
2026-02-25plans

title: content-orchestration Phase 2-A 구현 플랜 (L2) — 텔레그램 알림 + Brevo 고도화 date: 2026-02-25T19:00:00+09:00 type: impl-plan layer: L2 status: draft tags: [content-orchestration, phase2a, telegram, brevo, L2] author: phase2a-plan-pl project: content-orchestration

content-orchestration Phase 2-A 구현 플랜 (L2)

For Claude: REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.

Goal: 콘텐츠 파이프라인에 텔레그램 알림 시스템(DB 기반 알림 큐)과 Brevo 뉴스레터 고도화(예약 발송 + 3단계 발송 정책)를 구현하여, CEO/VP에게 파이프라인 이벤트를 자동 알림하고, 뉴스레터 발송을 단계적으로 안전하게 확장한다.

Architecture: Vercel 서버리스 환경에서 shell 실행이 불가하므로, 파이프라인이 pipeline_notifications 테이블에 알림 레코드를 INSERT하고, macOS 로컬 스크립트가 폴링하여 텔레그램으로 발송하는 비동기 큐 방식을 채택한다. Brevo는 기존 sendCampaignScheduled() 함수에 channels.config.schedule 기반 예약 발송과 send_tier 기반 3단계 발송 정책을 추가한다.

Tech Stack: TypeScript, Turso(LibSQL), @libsql/client/web, Next.js App Router, Brevo API(@getbrevo/brevo), Bash(notification-sender.sh)

프로젝트 경로: /Users/nbs22/(Claude)/(claude).projects/business-builder/projects/content-pipeline/

L1 설계서: docs/plans/2026/02/25/content-orchestration-design-phase2-channels.md

VP 지시: SNS 실연동 (Task 13) SKIP — getlate API 키 미설정, mock 유지. Task 48만 구현.


Task 4: pipeline_notifications 테이블 생성 (ensureSchema 확장)

Files:

  • Modify: src/lib/content-db.ts:117-166 (ensureSchema 함수)
  • Test: 직접 curl 호출로 검증

Step 1: content-db.ts의 ensureSchema()에 pipeline_notifications 테이블 CREATE 추가

src/lib/content-db.tsensureSchema() 함수 끝에 (기존 seed 데이터 INSERT 이후) 다음 코드를 추가한다:

  // Phase 2: pipeline_notifications 테이블
  await db.execute(`CREATE TABLE IF NOT EXISTS pipeline_notifications (
    id TEXT PRIMARY KEY,
    type TEXT NOT NULL,
    target TEXT NOT NULL,
    title TEXT NOT NULL,
    body TEXT NOT NULL,
    content_id TEXT,
    pipeline_log_id TEXT,
    error_log_id TEXT,
    status TEXT NOT NULL DEFAULT 'pending',
    sent_at INTEGER,
    error_message TEXT,
    created_at INTEGER NOT NULL,
    updated_at INTEGER NOT NULL
  )`).catch(() => {});
  await db.execute(`CREATE INDEX IF NOT EXISTS idx_notifications_status ON pipeline_notifications(status)`).catch(() => {});
  await db.execute(`CREATE INDEX IF NOT EXISTS idx_notifications_type ON pipeline_notifications(type)`).catch(() => {});

추가 위치: ensureSchema() 함수 내부, 기존 channels seed INSERT 문 (line 165) 아래.

Step 2: content-db.ts에 PipelineNotification 인터페이스 추가

기존 ErrorLog 인터페이스 아래(line 115)에 추가:

export interface PipelineNotification {
  id: string;
  type: string;        // 'draft_created' | 'qa_failed' | 'published' | 'error_escalation' | 'review_request'
  target: string;      // 'vp' | 'ceo'
  title: string;
  body: string;
  content_id: string | null;
  pipeline_log_id: string | null;
  error_log_id: string | null;
  status: string;      // 'pending' | 'sent' | 'failed'
  sent_at: number | null;
  error_message: string | null;
  created_at: number;
  updated_at: number;
}

Step 3: 로컬에서 빌드 확인

Run: cd /Users/nbs22/(Claude)/(claude).projects/business-builder/projects/content-pipeline && npx tsc --noEmit Expected: 컴파일 에러 없음 (기존 코드에 영향 없는 추가이므로)

Step 4: 커밋

cd /Users/nbs22/(Claude)/(claude).projects/business-builder
git add projects/content-pipeline/src/lib/content-db.ts
git pull --rebase origin main
git commit -m "feat(content-pipeline): add pipeline_notifications table to ensureSchema (Phase 2-A Task 4)"
git push origin main

Task 5: 알림 생성 함수 — lib/notifications.ts 신규 생성

Files:

  • Create: src/lib/notifications.ts
  • Test: TypeScript 컴파일 검증

의존성: Task 4 완료 필수 (pipeline_notifications 테이블 존재)

Step 1: src/lib/notifications.ts 파일 생성

// projects/content-pipeline/src/lib/notifications.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!,
  });
}

type NotificationType = 'draft_created' | 'qa_failed' | 'published' | 'error_escalation' | 'review_request';
type NotificationTarget = 'vp' | 'ceo';

/**
 * pipeline_notifications 테이블에 알림 레코드 INSERT.
 * Vercel 서버리스 환경에서의 역할은 여기까지.
 * 실제 텔레그램 발송은 macOS 로컬 스크립트(notification-sender.sh)가 폴링 처리.
 */
async function insertNotification(
  type: NotificationType,
  target: NotificationTarget,
  title: string,
  body: string,
  options?: {
    contentId?: string;
    pipelineLogId?: string;
    errorLogId?: string;
  },
): Promise<string> {
  const db = getContentDb();
  const id = crypto.randomUUID();
  const now = Date.now();

  await db.execute({
    sql: `INSERT INTO pipeline_notifications
          (id, type, target, title, body, content_id, pipeline_log_id, error_log_id, status, created_at, updated_at)
          VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'pending', ?, ?)`,
    args: [
      id, type, target, title, body,
      options?.contentId || null,
      options?.pipelineLogId || null,
      options?.errorLogId || null,
      now, now,
    ],
  });

  console.log(`[notifications] 알림 생성: type=${type}, target=${target}, id=${id}`);
  return id;
}

/**
 * 콘텐츠 생성 완료 (draft) 알림 — VP 대상.
 *
 * stage-generate.ts에서 성공 후 호출.
 * 메시지: "콘텐츠 생성 완료\n제목: {title}\nQA: {score}/8\n미리보기: {url}"
 */
export async function notifyDraftCreated(
  contentId: string,
  title: string,
  qaScore: number,
  previewUrl: string,
): Promise<void> {
  const body = [
    '콘텐츠 생성 완료',
    `제목: ${title}`,
    `QA: ${qaScore}/8`,
    `미리보기: ${previewUrl}`,
  ].join('\n');

  await insertNotification('draft_created', 'vp', `콘텐츠 생성: ${title}`, body, { contentId });
}

/**
 * QA 최종 실패 알림 — VP 대상.
 *
 * stage-generate.ts에서 모든 재시도 후 최종 실패 시 호출.
 * 메시지: "QA 최종 실패\n제목: {topic}\n점수: {score}/8\n시도: {attempts}회"
 */
export async function notifyQaFailed(
  topic: string,
  score: number,
  attempts: number,
): Promise<void> {
  const body = [
    'QA 최종 실패',
    `토픽: ${topic}`,
    `점수: ${score}/8`,
    `시도: ${attempts}회`,
  ].join('\n');

  await insertNotification('qa_failed', 'vp', `QA 실패: ${topic}`, body);
}

/**
 * 배포 완료 알림 — VP 대상.
 *
 * stage-publish.ts에서 성공 후 호출.
 * 메시지: "콘텐츠 배포 완료\n제목: {title}\n채널: {channels}\n블로그: {url}"
 */
export async function notifyPublished(
  contentId: string,
  title: string,
  channels: string[],
  blogUrl: string,
): Promise<void> {
  const body = [
    '콘텐츠 배포 완료',
    `제목: ${title}`,
    `채널: ${channels.join(', ')}`,
    `블로그: ${blogUrl}`,
  ].join('\n');

  await insertNotification('published', 'vp', `배포 완료: ${title}`, body, { contentId });
}

/**
 * 에러 에스컬레이션 알림 — VP 대상.
 *
 * self-healing.ts의 escalateL5() 호출 후 호출.
 * 메시지: "파이프라인 에러\n컴포넌트: {component}\n에러: {message}\n에러ID: {id}"
 */
export async function notifyErrorEscalation(
  component: string,
  errorMessage: string,
  errorLogId: string,
): Promise<void> {
  const body = [
    '파이프라인 에러 에스컬레이션',
    `컴포넌트: ${component}`,
    `에러: ${errorMessage}`,
    `에러ID: ${errorLogId}`,
  ].join('\n');

  await insertNotification('error_escalation', 'vp', `에러: ${component}`, body, { errorLogId });
}

/**
 * CEO 검수 요청 알림 — CEO 대상.
 *
 * 향후 approved → reviewing 전환 시 호출.
 * 메시지: "콘텐츠 검수 요청\n제목: {title}\n미리보기: {url}\n승인/거부 부탁드립니다"
 */
export async function notifyReviewRequest(
  contentId: string,
  title: string,
  previewUrl: string,
): Promise<void> {
  const body = [
    '콘텐츠 검수 요청',
    `제목: ${title}`,
    `미리보기: ${previewUrl}`,
    '승인/거부 부탁드립니다',
  ].join('\n');

  await insertNotification('review_request', 'ceo', `검수 요청: ${title}`, body, { contentId });
}

Step 2: TypeScript 컴파일 확인

Run: cd /Users/nbs22/(Claude)/(claude).projects/business-builder/projects/content-pipeline && npx tsc --noEmit Expected: 에러 없음

Step 3: 커밋

cd /Users/nbs22/(Claude)/(claude).projects/business-builder
git add projects/content-pipeline/src/lib/notifications.ts
git pull --rebase origin main
git commit -m "feat(content-pipeline): add notifications.ts — 5 notification functions for pipeline events (Phase 2-A Task 5)"
git push origin main

Task 6: 파이프라인 알림 연동 — stage-generate.ts + stage-publish.ts 수정

Files:

  • Modify: src/pipeline/stage-generate.ts:1-245
  • Modify: src/pipeline/stage-publish.ts:1-181
  • Modify: src/lib/self-healing.ts:451-461 (escalateL5 함수)
  • Test: TypeScript 컴파일 검증

의존성: Task 5 완료 필수 (notifications.ts 존재)

Step 1: stage-generate.ts에 알림 import 추가

파일 상단 import 블록 (line 1~19)에 추가:

import { notifyDraftCreated, notifyQaFailed } from '../lib/notifications';

Step 2: stage-generate.ts — 생성 성공 시 notifyDraftCreated 호출

runGenerateStage() 함수 내, line 167~168 (content_queue 저장 완료 후, pipeline_logs 완료 전)에 알림 추가:

현재 코드 (line 166~168):

    // 5. content_queue에 draft 저장
    const cqId = await saveToContentQueue(post, pillar, qaScore);
    console.log(`[stage-generate] content_queue 저장 완료: id=${cqId}, title="${post.title}"`);

변경 후:

    // 5. content_queue에 draft 저장
    const cqId = await saveToContentQueue(post, pillar, qaScore);
    console.log(`[stage-generate] content_queue 저장 완료: id=${cqId}, title="${post.title}"`);

    // 5-1. 텔레그램 알림: 콘텐츠 생성 완료
    const previewUrl = `https://content-pipeline-sage.vercel.app/pipeline/review`;
    try {
      await notifyDraftCreated(cqId, post.title, qaScore, previewUrl);
    } catch (notifyErr) {
      console.warn(`[stage-generate] 알림 생성 실패 (무시): ${notifyErr}`);
    }

Step 3: stage-generate.ts — 최종 실패 시 notifyQaFailed 호출

runGenerateStage() 함수 내, line 160~163 (최종 실패 처리)에 알림 추가:

현재 코드 (line 160~163):

    if (!post) {
      const errId = await logError('ai_generator', 'api_error', `${MAX_RETRIES + 1}회 시도 후 최종 실패`);
      await logPipelineFailed(pipelineLog.id, '콘텐츠 생성 최종 실패', errId);
      return { success: false, contentQueueId: null, title: null, qaScore: 0, pipelineLogId: pipelineLog.id };
    }

변경 후:

    if (!post) {
      const errId = await logError('ai_generator', 'api_error', `${MAX_RETRIES + 1}회 시도 후 최종 실패`);
      await logPipelineFailed(pipelineLog.id, '콘텐츠 생성 최종 실패', errId);

      // 텔레그램 알림: QA 최종 실패
      try {
        await notifyQaFailed(finalTopic, qaScore, MAX_RETRIES + 1);
      } catch (notifyErr) {
        console.warn(`[stage-generate] 알림 생성 실패 (무시): ${notifyErr}`);
      }

      return { success: false, contentQueueId: null, title: null, qaScore: 0, pipelineLogId: pipelineLog.id };
    }

Step 4: stage-publish.ts에 알림 import 추가

파일 상단 import 블록 (line 1~10)에 추가:

import { notifyPublished } from '../lib/notifications';

Step 5: stage-publish.ts — 배포 성공 시 notifyPublished 호출

runPublishStage() 함수 내, line 119~136 (hasSuccess 블록, console.log 직전)에 알림 추가:

현재 코드 (line 136):

      console.log(`[stage-publish] 완료: ${item.id} -> published (${orchResult.successCount}/${orchResult.totalChannels} channels)`);

이 줄 바로 위에 추가:

      // 텔레그램 알림: 배포 완료
      const successChannelNames = orchResult.channels.filter(c => c.success).map(c => c.channelName);
      const blogResult = orchResult.channels.find(c => c.type === 'blog' && c.success);
      const blogUrl = blogResult?.platformUrl || 'https://apppro.kr/blog';
      try {
        await notifyPublished(item.id, item.title, successChannelNames, blogUrl);
      } catch (notifyErr) {
        console.warn(`[stage-publish] 알림 생성 실패 (무시): ${notifyErr}`);
      }

Step 6: self-healing.ts — escalateL5에 에러 알림 추가

파일 import 블록 (line 1~11)에 추가:

import { notifyErrorEscalation } from './notifications';

escalateL5() 함수 (line 451~461)를 수정:

현재 코드 (line 457~461):

  const errorLogId = await logError(component, errorType, errorMessage, options);
  await markEscalated(errorLogId);
  console.error(`[self-healing:L5] 에스컬레이션: ${component}:${errorType} — ${errorMessage}`);
  return errorLogId;

변경 후:

  const errorLogId = await logError(component, errorType, errorMessage, options);
  await markEscalated(errorLogId);
  console.error(`[self-healing:L5] 에스컬레이션: ${component}:${errorType} — ${errorMessage}`);

  // 텔레그램 알림: 에러 에스컬레이션
  try {
    await notifyErrorEscalation(component, errorMessage, errorLogId);
  } catch (notifyErr) {
    console.warn(`[self-healing:L5] 알림 생성 실패 (무시): ${notifyErr}`);
  }

  return errorLogId;

Step 7: TypeScript 컴파일 확인

Run: cd /Users/nbs22/(Claude)/(claude).projects/business-builder/projects/content-pipeline && npx tsc --noEmit Expected: 에러 없음

Step 8: 커밋

cd /Users/nbs22/(Claude)/(claude).projects/business-builder
git add projects/content-pipeline/src/pipeline/stage-generate.ts \
        projects/content-pipeline/src/pipeline/stage-publish.ts \
        projects/content-pipeline/src/lib/self-healing.ts
git pull --rebase origin main
git commit -m "feat(content-pipeline): integrate notifications into stage-generate, stage-publish, self-healing (Phase 2-A Task 6)"
git push origin main

Task 7: Brevo 예약 발송 — channels.config.schedule 기반

Files:

  • Modify: src/lib/publish-orchestrator.ts:180-289 (publishToBrevoChannel 함수)
  • Test: TypeScript 컴파일 검증

의존성: 없음 (독립 작업)

Step 1: publish-orchestrator.ts에 예약 발송 스케줄 계산 함수 추가

파일 상단, import 블록 (line 1~5) 아래에 헬퍼 함수 추가:

/**
 * channels.config.schedule 기반으로 예약 발송 시각을 계산한다.
 *
 * schedule.type:
 * - "immediate": null (즉시 발송, 기본값)
 * - "delay": now + delay_hours (ISO 8601)
 * - "fixed": 다음 매칭 요일/시간 계산
 *
 * @returns ISO 8601 문자열 또는 null (즉시 발송)
 */
function calculateScheduledAt(config: Record<string, unknown>): string | null {
  const schedule = config.schedule as { type?: string; delay_hours?: number; day?: string; time?: string } | undefined;
  if (!schedule || !schedule.type || schedule.type === 'immediate') {
    return null;
  }

  if (schedule.type === 'delay' && typeof schedule.delay_hours === 'number') {
    const scheduledDate = new Date(Date.now() + schedule.delay_hours * 60 * 60 * 1000);
    return scheduledDate.toISOString();
  }

  if (schedule.type === 'fixed' && schedule.day && schedule.time) {
    const dayMap: Record<string, number> = {
      sunday: 0, monday: 1, tuesday: 2, wednesday: 3,
      thursday: 4, friday: 5, saturday: 6,
    };
    const targetDay = dayMap[schedule.day.toLowerCase()];
    if (targetDay === undefined) return null;

    const [hours, minutes] = schedule.time.split(':').map(Number);
    const now = new Date();
    const currentDay = now.getDay();

    let daysUntil = targetDay - currentDay;
    if (daysUntil <= 0) daysUntil += 7;

    const scheduled = new Date(now);
    scheduled.setDate(scheduled.getDate() + daysUntil);
    scheduled.setHours(hours, minutes, 0, 0);

    return scheduled.toISOString();
  }

  return null;
}

Step 2: publishToBrevoChannel()에서 schedule 활용하도록 수정

현재 코드 (line 225):

    const result = await sendCampaignScheduled(listId, title, htmlContent, null);

변경 후:

    // 예약 발송: channels.config.schedule 기반
    const scheduledAt = calculateScheduledAt(config);
    if (scheduledAt) {
      console.log(`[orchestrator] Brevo 예약 발송: ${scheduledAt}`);
    }

    const result = await sendCampaignScheduled(listId, title, htmlContent, scheduledAt);

Step 3: 발송 성공 시 content_distributions에 scheduled_at 기록

현재 코드 (line 241~248):

    const campaignId = result.campaignId!;
    const now = Date.now();

    // content_distributions — 발송 완료
    const distId = await insertDistribution(
      contentId, channel.id, 'published',
      String(campaignId), null, null, null, now,
    );

변경 후:

    const campaignId = result.campaignId!;
    const now = Date.now();

    // content_distributions — 예약 발송이면 'registered', 즉시면 'published'
    const platformStatus = scheduledAt ? 'registered' : 'published';
    const scheduledAtMs = scheduledAt ? new Date(scheduledAt).getTime() : null;

    const distId = await insertDistribution(
      contentId, channel.id, platformStatus,
      String(campaignId), null, null, scheduledAtMs, scheduledAt ? null : now,
    );

Step 4: console.log 메시지도 예약/즉시 구분

현재 코드 (line 250):

    console.log(`[orchestrator] Brevo 캠페인 발송 완료: campaignId=${campaignId}`);

변경 후:

    console.log(`[orchestrator] Brevo 캠페인 ${scheduledAt ? '예약' : '즉시'} 발송 완료: campaignId=${campaignId}${scheduledAt ? `, scheduled=${scheduledAt}` : ''}`);

Step 5: TypeScript 컴파일 확인

Run: cd /Users/nbs22/(Claude)/(claude).projects/business-builder/projects/content-pipeline && npx tsc --noEmit Expected: 에러 없음

Step 6: 커밋

cd /Users/nbs22/(Claude)/(claude).projects/business-builder
git add projects/content-pipeline/src/lib/publish-orchestrator.ts
git pull --rebase origin main
git commit -m "feat(content-pipeline): add Brevo scheduled send via channels.config.schedule (Phase 2-A Task 7)"
git push origin main

Task 8: Brevo 3단계 발송 정책 — send_tier + requires_ceo_approval

Files:

  • Modify: src/lib/publish-orchestrator.ts:180-289 (publishToBrevoChannel 함수)
  • Test: TypeScript 컴파일 검증

의존성: Task 7 완료 필수 (publishToBrevoChannel에 schedule 변경 적용 후)

Step 1: publish-orchestrator.ts에 3단계 정책 타입 및 헬퍼 추가

calculateScheduledAt() 함수 아래에 추가:

/**
 * Brevo 3단계 발송 정책 (brevo-email-policy.md, CEO 확정).
 *
 * send_tier: "test" | "pilot" | "production"
 * - test: 테스트 리스트(3~5명), 자율 발송
 * - pilot: 파일럿 리스트(10~20명), 자율 발송
 * - production: 전체 구독자, CEO 승인 필수
 *
 * @returns { listId, blocked, reason } — blocked=true면 발송 차단
 */
function resolveSendTier(config: Record<string, unknown>): {
  listId: number;
  blocked: boolean;
  reason: string | null;
} {
  const sendTier = (config.send_tier as string) || 'test';
  const tierConfig = config.tier_config as Record<string, {
    list_id: number;
    auto: boolean;
    requires_ceo_approval?: boolean;
  }> | undefined;

  if (!tierConfig || !tierConfig[sendTier]) {
    // tier_config 없으면 기존 방식 (config.list_id 사용)
    return {
      listId: (config.list_id as number) || 0,
      blocked: false,
      reason: null,
    };
  }

  const tier = tierConfig[sendTier];

  if (tier.requires_ceo_approval) {
    return {
      listId: tier.list_id,
      blocked: true,
      reason: `send_tier="${sendTier}" requires CEO approval — 자동 발송 차단`,
    };
  }

  if (!tier.auto) {
    return {
      listId: tier.list_id,
      blocked: true,
      reason: `send_tier="${sendTier}" auto=false — 수동 발송만 허용`,
    };
  }

  return {
    listId: tier.list_id,
    blocked: false,
    reason: null,
  };
}

Step 2: publishToBrevoChannel()에서 3단계 정책 적용

publishToBrevoChannel() 내, listId 결정 부분을 수정.

현재 코드 (line 200~205):

    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)');
    }

변경 후:

    const config = parseChannelConfig(channel);

    // 3단계 발송 정책 적용 (brevo-email-policy.md)
    const tierResult = resolveSendTier(config);
    const listId = tierResult.listId || parseInt(process.env.BREVO_LIST_ID || '0', 10);

    if (tierResult.blocked) {
      console.log(`[orchestrator] Brevo 발송 차단: ${tierResult.reason}`);
      const distId = await insertDistribution(
        contentId, channel.id, 'blocked', null, null,
        tierResult.reason, null, null,
      );
      return {
        channelId: channel.id, channelName: channel.name, type: channel.type,
        success: false, mock: false, platformId: null, platformUrl: null,
        distributionId: distId, error: tierResult.reason,
      };
    }

    if (!listId) {
      throw new Error('Brevo list_id 미설정 (channels.config.list_id / tier_config / BREVO_LIST_ID)');
    }

    console.log(`[orchestrator] Brevo send_tier="${(config.send_tier as string) || 'default'}", listId=${listId}`);

Step 3: TypeScript 컴파일 확인

Run: cd /Users/nbs22/(Claude)/(claude).projects/business-builder/projects/content-pipeline && npx tsc --noEmit Expected: 에러 없음

Step 4: 커밋

cd /Users/nbs22/(Claude)/(claude).projects/business-builder
git add projects/content-pipeline/src/lib/publish-orchestrator.ts
git pull --rebase origin main
git commit -m "feat(content-pipeline): add Brevo 3-tier send policy with CEO approval gate (Phase 2-A Task 8)"
git push origin main

부록 A: channels.config 업데이트 SQL (Task 7~8 활성화 시 실행)

현재 ch-brevo 채널의 config를 Phase 2 스키마로 업데이트하는 SQL. 구현 PL이 Task 7~8 완료 후 실행:

UPDATE channels
SET config = '{"list_id":8,"template":"weekly","sender_name":"AI AppPro","sender_email":"hello@apppro.kr","send_tier":"test","tier_config":{"test":{"list_id":8,"auto":true},"pilot":{"list_id":0,"auto":true},"production":{"list_id":0,"auto":false,"requires_ceo_approval":true}},"schedule":{"type":"immediate"}}',
    updated_at = unixepoch() * 1000
WHERE id = 'ch-brevo';

이 SQL은 ensureSchema()에 추가하지 않는다 (기존 config가 있으면 덮어쓰므로). 대시보드 또는 수동 실행으로 처리.


부록 B: notification-sender.sh 스크립트 (macOS 로컬, Phase 2-B)

Task 4~6에서 알림 레코드가 pipeline_notifications 테이블에 쌓이면, 이 스크립트가 폴링하여 텔레그램으로 발송한다. Phase 2-B에서 구현 예정 (L1 설계서 Section 4-7).

#!/bin/bash
# scripts/notification-sender.sh
# macOS LaunchAgent로 5분마다 실행

CONTENT_OS_DB="content-os"

# 1. pending 알림 조회
PENDING=$(turso db shell $CONTENT_OS_DB \
  "SELECT id, target, body FROM pipeline_notifications WHERE status='pending' ORDER BY created_at ASC LIMIT 10" \
  --output json 2>/dev/null)

# 2. 각 알림 처리 (jq로 JSON 파싱)
echo "$PENDING" | jq -c '.[]' 2>/dev/null | while read -r row; do
  ID=$(echo "$row" | jq -r '.id')
  TARGET=$(echo "$row" | jq -r '.target')
  BODY=$(echo "$row" | jq -r '.body')

  if [ "$TARGET" = "vp" ]; then
    vice-reply.sh "$BODY" "pipeline-alert"
  elif [ "$TARGET" = "ceo" ]; then
    ceo-reply.sh "$BODY" "vice-claude"
  fi

  # 3. 발송 성공 시 status 업데이트
  NOW_MS=$(($(date +%s) * 1000))
  turso db shell $CONTENT_OS_DB \
    "UPDATE pipeline_notifications SET status='sent', sent_at=$NOW_MS, updated_at=$NOW_MS WHERE id='$ID'"
done

이 스크립트는 Phase 2-B Task 11에서 정교화하여 구현 예정. 위는 개념 참조용.


작업 요약

Task설명파일상태
4pipeline_notifications 테이블 생성content-db.ts대기
5알림 생성 함수 5종notifications.ts (신규)대기
6파이프라인 알림 연동stage-generate.ts, stage-publish.ts, self-healing.ts대기
7Brevo 예약 발송publish-orchestrator.ts대기
8Brevo 3단계 발송 정책publish-orchestrator.ts대기

의존성 체인: Task 4 → Task 5 → Task 6 (순차), Task 7 → Task 8 (순차), Task 46과 Task 78은 독립 (병렬 가능)

예상 소요: 약 3045분 (5개 Task, 각 510분)


리뷰 로그

[phase2a-plan-pl 초안 작성] 2026-02-25T19:00:00+09:00

  • L1 설계서 (content-orchestration-design-phase2-channels.md) 기반으로 L2 구현 플랜 작성
  • 기존 코드 8개 파일 분석: content-db.ts, notifications.ts(신규), publish-orchestrator.ts, stage-generate.ts, stage-publish.ts, self-healing.ts, channels.ts, brevo.ts
  • SNS Task 1~3 SKIP (VP 지시: getlate API 키 미설정)
  • Task 4: pipeline_notifications CREATE TABLE + 인덱스 + PipelineNotification 인터페이스
  • Task 5: notifications.ts 신규 — notifyDraftCreated, notifyQaFailed, notifyPublished, notifyErrorEscalation, notifyReviewRequest (5종)
  • Task 6: stage-generate(성공/실패 알림), stage-publish(배포 알림), self-healing(에스컬레이션 알림) 연동
  • Task 7: calculateScheduledAt() 헬퍼 + publishToBrevoChannel에 schedule 기반 예약 발송
  • Task 8: resolveSendTier() 헬퍼 + 3단계 정책(test/pilot/production) + requires_ceo_approval 차단
  • 모든 알림 호출은 try-catch로 감싸서 알림 실패가 파이프라인을 중단하지 않도록 처리
  • 부록: channels.config 업데이트 SQL + notification-sender.sh 참조 코드
plans/2026/02/25/content-orchestration-impl-phase2a.md