← 목록으로
2026-02-25plans

title: content-orchestration Phase 2-B 구현 플랜 (Tasks 9/10/12/13) date: 2026-02-25T22:30:00+09:00 type: implementation-plan layer: L2 status: draft author: phase2b-plan-pl reviewed_by: "" reviewed_at: "" approved_by: "" approved_at: "" tags: [content-orchestration, phase2b, brevo-polling, metrics, notifications-api, email-template, L2] project: content-orchestration

content-orchestration Phase 2-B 구현 플랜 (Tasks 9/10/12/13)

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

Goal: Brevo 캠페인 메트릭 자동 추적, 파이프라인 알림 조회 API, HTML 이메일 템플릿을 구현하여 콘텐츠 배포 후 성과 측정 및 알림 가시성을 확보한다.

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

Phase 2-A 플랜: docs/plans/2026/02/25/content-orchestration-impl-phase2a.md (Tasks 4-8, approved)

Phase 2 백로그: docs/plans/2026/02/25/content-orchestration-phase2-backlog.md (EXT-2, UI 항목)

제외: Task 11 (notification-sender.sh) — 별도 PL이 구현 중.


의존성 체인

Task 10 (metrics 컬럼) → Task 9 (Brevo 캠페인 폴링)
Task 12 (알림 조회 API) — 독립
Task 13 (HTML 이메일 템플릿) → publish-orchestrator.ts 연동

권장 실행 순서: Task 10 → Task 9 → Task 12 → Task 13 (순차) 또는 Task 10 → (Task 9 + Task 12 병렬) → Task 13


Task 10: content_distributions 테이블에 metrics 컬럼 추가

목적: Brevo 캠페인 통계(오픈율, 클릭율 등)를 저장할 metrics TEXT 컬럼 추가. Task 9의 선행 조건.

Files:

  • Modify: src/lib/content-db.ts (ensureSchema + ContentDistribution 인터페이스)

Step 1: ContentDistribution 인터페이스에 metrics 필드 추가

src/lib/content-db.tsContentDistribution 인터페이스 (line 86~99)를 수정:

현재 코드:

export interface ContentDistribution {
  id: string;
  content_id: string;
  channel_id: string;
  platform_status: string;
  platform_id: string | null;
  platform_url: string | null;
  scheduled_at: number | null;
  published_at: number | null;
  error_message: string | null;
  retry_count: number;
  created_at: number;
  updated_at: number;
}

변경 후:

export interface ContentDistribution {
  id: string;
  content_id: string;
  channel_id: string;
  platform_status: string;
  platform_id: string | null;
  platform_url: string | null;
  scheduled_at: number | null;
  published_at: number | null;
  error_message: string | null;
  retry_count: number;
  metrics: string | null;      // JSON string: { sent, delivered, opened, clicked, openRate, clickRate, ... }
  created_at: number;
  updated_at: number;
}

Step 2: ensureSchema()에 ALTER TABLE 추가

src/lib/content-db.tsensureSchema() 함수 내, pipeline_notifications CREATE TABLE 직전 (line 182 부근)에 추가:

  // Phase 2-B: content_distributions metrics 컬럼
  await db.execute(`ALTER TABLE content_distributions ADD COLUMN metrics TEXT`).catch(() => {});

추가 위치: ensureSchema() 함수 내, 기존 channels seed INSERT 문 아래, pipeline_notifications CREATE TABLE 위.

Step 3: TypeScript 컴파일 확인

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 metrics TEXT column to content_distributions (Phase 2-B Task 10)"
git push origin main

Task 9: Brevo 캠페인 상태 폴링

목적: 발송 완료된 Brevo 캠페인의 오픈율/클릭률 등 통계를 자동 수집하여 content_distributions.metrics에 저장. 백로그 EXT-2 구현.

의존성: Task 10 완료 필수 (metrics 컬럼 존재)

Files:

  • Modify: src/lib/brevo.ts (getCampaignStats 함수 추가)
  • Create: src/pipeline/poll-brevo.ts (폴링 스크립트)

Step 1: brevo.ts에 getCampaignStats() 함수 추가

src/lib/brevo.ts 파일 끝 (line 264, getBrevoStatus() 함수 아래)에 추가:

// ============================================================
// Campaign Statistics (Phase 2-B: Brevo 캠페인 메트릭 폴링)
// ============================================================

export interface CampaignStats {
  campaignId: number;
  status: string;          // 'draft' | 'sent' | 'archive' | 'queued' | 'suspended' | 'in_process'
  sent: number;
  delivered: number;
  opened: number;          // uniqueViews
  clicked: number;         // uniqueClicks
  openRate: number;        // opensRate (percentage)
  unsubscribed: number;
  hardBounces: number;
  softBounces: number;
  sentDate: string | null; // ISO 8601
}

/**
 * Brevo 캠페인 통계 조회.
 * getEmailCampaign API로 캠페인 상태 + globalStats를 가져온다.
 *
 * @param campaignId Brevo 캠페인 ID (content_distributions.platform_id)
 * @returns CampaignStats 또는 null (mock 모드 / 에러 시)
 */
export async function getCampaignStats(campaignId: number): Promise<CampaignStats | null> {
  if (isMockMode()) {
    console.log(`[brevo:mock] getCampaignStats(${campaignId}) — mock 모드`);
    return null;
  }

  try {
    const client = getClient();
    const response = await client.emailCampaigns.getEmailCampaign({ campaignId });
    const data = response as unknown as {
      id: number;
      status: string;
      sentDate?: string;
      statistics: {
        globalStats: {
          sent: number;
          delivered: number;
          uniqueViews: number;
          uniqueClicks: number;
          opensRate: number;
          unsubscriptions: number;
          hardBounces: number;
          softBounces: number;
        };
      };
    };

    const gs = data.statistics.globalStats;

    return {
      campaignId: data.id,
      status: data.status,
      sent: gs.sent,
      delivered: gs.delivered,
      opened: gs.uniqueViews,
      clicked: gs.uniqueClicks,
      openRate: gs.opensRate,
      unsubscribed: gs.unsubscriptions,
      hardBounces: gs.hardBounces,
      softBounces: gs.softBounces,
      sentDate: data.sentDate || null,
    };
  } catch (err: unknown) {
    const message = err instanceof Error ? err.message : String(err);
    console.error(`[brevo] getCampaignStats(${campaignId}) 오류: ${message}`);
    return null;
  }
}

Step 2: poll-brevo.ts 신규 생성

src/pipeline/poll-brevo.ts 파일 생성:

// projects/content-pipeline/src/pipeline/poll-brevo.ts
/**
 * Brevo 캠페인 메트릭 폴링.
 *
 * content_distributions에서 channel_id='ch-brevo' AND platform_status IN ('published','registered')
 * 인 레코드를 조회하고, 각 캠페인의 최신 통계를 Brevo API로 가져와 metrics 컬럼에 저장한다.
 *
 * 실행: Vercel Cron 또는 수동 API 호출.
 * 폴링 조건: metrics가 NULL이거나, updated_at이 1시간 이상 경과한 레코드.
 */

import { createClient } from '@libsql/client/web';
import { getCampaignStats, getBrevoStatus } from '../lib/brevo';

function getContentDb() {
  return createClient({
    url: process.env.CONTENT_OS_DB_URL!,
    authToken: process.env.CONTENT_OS_DB_TOKEN!,
  });
}

export interface PollBrevoResult {
  polled: number;
  updated: number;
  errors: number;
}

/**
 * Brevo 캠페인 메트릭 폴링 실행.
 *
 * 1. content_distributions에서 폴링 대상 조회
 * 2. 각 레코드의 platform_id(=campaignId)로 getCampaignStats() 호출
 * 3. metrics TEXT 컬럼 업데이트 (JSON.stringify)
 * 4. Brevo 캠페인 상태가 'sent'/'archive'이면 platform_status도 'published'로 업데이트
 */
export async function runPollBrevo(): Promise<PollBrevoResult> {
  const brevoStatus = getBrevoStatus();
  if (!brevoStatus.configured) {
    console.log('[poll-brevo] Brevo API 미설정, 스킵');
    return { polled: 0, updated: 0, errors: 0 };
  }

  const db = getContentDb();
  const oneHourAgo = Date.now() - 60 * 60 * 1000;

  // 폴링 대상: ch-brevo 채널, published/registered 상태, metrics가 NULL이거나 1시간 이상 경과
  const result = await db.execute({
    sql: `SELECT id, platform_id, platform_status, metrics, updated_at
          FROM content_distributions
          WHERE channel_id = 'ch-brevo'
            AND platform_status IN ('published', 'registered')
            AND platform_id IS NOT NULL
            AND (metrics IS NULL OR updated_at < ?)
          ORDER BY updated_at ASC
          LIMIT 10`,
    args: [oneHourAgo],
  });

  const rows = result.rows as unknown as {
    id: string;
    platform_id: string;
    platform_status: string;
    metrics: string | null;
    updated_at: number;
  }[];

  if (rows.length === 0) {
    console.log('[poll-brevo] 폴링 대상 없음');
    return { polled: 0, updated: 0, errors: 0 };
  }

  console.log(`[poll-brevo] 폴링 대상 ${rows.length}건`);

  let updated = 0;
  let errors = 0;

  for (const row of rows) {
    const campaignId = parseInt(row.platform_id, 10);
    if (isNaN(campaignId)) {
      console.warn(`[poll-brevo] platform_id 파싱 실패: ${row.platform_id} (dist.id=${row.id})`);
      errors++;
      continue;
    }

    const stats = await getCampaignStats(campaignId);
    if (!stats) {
      errors++;
      continue;
    }

    const now = Date.now();
    const metricsJson = JSON.stringify(stats);

    // metrics 업데이트 + platform_status 동기화
    const newStatus = (stats.status === 'sent' || stats.status === 'archive') ? 'published' : row.platform_status;

    await db.execute({
      sql: `UPDATE content_distributions
            SET metrics = ?, platform_status = ?, updated_at = ?
            WHERE id = ?`,
      args: [metricsJson, newStatus, now, row.id],
    });

    console.log(`[poll-brevo] 업데이트: dist.id=${row.id}, campaign=${campaignId}, sent=${stats.sent}, openRate=${stats.openRate}%`);
    updated++;
  }

  console.log(`[poll-brevo] 완료: polled=${rows.length}, updated=${updated}, errors=${errors}`);
  return { polled: rows.length, updated, errors };
}

Step 3: TypeScript 컴파일 확인

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/brevo.ts \
        projects/content-pipeline/src/pipeline/poll-brevo.ts
git pull --rebase origin main
git commit -m "feat(content-pipeline): add Brevo campaign stats polling — getCampaignStats + poll-brevo.ts (Phase 2-B Task 9)"
git push origin main

Task 12: 알림 조회 API 엔드포인트

목적: pipeline_notifications 테이블의 알림 목록을 조회하는 Next.js API 엔드포인트. 대시보드 UI에서 알림 현황을 표시하기 위한 데이터 소스.

의존성: 없음 (pipeline_notifications 테이블은 Phase 2-A Task 4에서 이미 생성됨)

Files:

  • Create: src/app/api/pipeline/notifications/route.ts

Step 1: notifications/route.ts 신규 생성

src/app/api/pipeline/notifications/route.ts 파일 생성:

// projects/content-pipeline/src/app/api/pipeline/notifications/route.ts
import { createClient } from '@libsql/client/web';
import { NextRequest, NextResponse } from 'next/server';

function getContentDb() {
  return createClient({
    url: process.env.CONTENT_OS_DB_URL!,
    authToken: process.env.CONTENT_OS_DB_TOKEN!,
  });
}

/**
 * GET /api/pipeline/notifications?status=&type=&target=&page=1&limit=50
 *
 * pipeline_notifications 조회. 필터 + 페이지네이션.
 *
 * Query params:
 * - status: 'pending' | 'sent' | 'failed' | 'all' (기본: 'all')
 * - type: 'draft_created' | 'qa_failed' | 'published' | 'error_escalation' | 'review_request' (선택)
 * - target: 'vp' | 'ceo' (선택)
 * - page: 페이지 번호 (기본: 1)
 * - limit: 페이지 크기 (기본: 50, 최대: 100)
 *
 * 응답: { notifications: [...], total: N, page, limit }
 */
export async function GET(req: NextRequest) {
  try {
    const db = getContentDb();
    const sp = req.nextUrl.searchParams;

    const status = sp.get('status') || 'all';
    const type = sp.get('type');
    const target = sp.get('target');
    const page = Math.max(1, Number(sp.get('page')) || 1);
    const limit = Math.min(100, Math.max(1, Number(sp.get('limit')) || 50));
    const offset = (page - 1) * limit;

    // WHERE 조건 빌드
    const conditions: string[] = [];
    const args: (string | number)[] = [];

    if (status && status !== 'all') {
      conditions.push('status = ?');
      args.push(status);
    }
    if (type) {
      conditions.push('type = ?');
      args.push(type);
    }
    if (target) {
      conditions.push('target = ?');
      args.push(target);
    }

    const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '';

    // 전체 개수
    const countRes = await db.execute({
      sql: `SELECT COUNT(*) as cnt FROM pipeline_notifications ${whereClause}`,
      args,
    });
    const total = Number((countRes.rows[0] as Record<string, unknown>).cnt) || 0;

    // 알림 목록
    const listRes = await db.execute({
      sql: `SELECT id, type, target, title, body, content_id, pipeline_log_id, error_log_id,
                   status, sent_at, error_message, created_at, updated_at
            FROM pipeline_notifications ${whereClause}
            ORDER BY created_at DESC
            LIMIT ? OFFSET ?`,
      args: [...args, limit, offset],
    });

    return NextResponse.json({
      notifications: listRes.rows,
      total,
      page,
      limit,
    });
  } catch (err) {
    console.error('[notifications] Error:', err);
    return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
  }
}

Step 2: TypeScript 컴파일 확인

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/app/api/pipeline/notifications/route.ts
git pull --rebase origin main
git commit -m "feat(content-pipeline): add /api/pipeline/notifications GET endpoint (Phase 2-B Task 12)"
git push origin main

Task 13: HTML 이메일 템플릿 + publish-orchestrator 연동

목적: Brevo 캠페인용 HTML 이메일 템플릿 생성 함수를 만들고, publish-orchestrator.ts의 publishToBrevoChannel()에서 이 함수를 사용하도록 연동. 현재 인라인 HTML 생성 코드를 정식 템플릿으로 교체.

의존성: 없음 (독립 작업이지만 Task 10 이후 실행 권장)

Files:

  • Create: src/lib/email-templates.ts
  • Modify: src/lib/publish-orchestrator.ts (publishToBrevoChannel 함수)

Step 1: email-templates.ts 신규 생성

src/lib/email-templates.ts 파일 생성:

// projects/content-pipeline/src/lib/email-templates.ts
/**
 * Brevo 캠페인용 HTML 이메일 템플릿.
 *
 * AI AppPro 브랜딩 적용. 한국어. 반응형.
 * unsubscribe 링크는 Brevo가 자동 삽입하는 {{ unsubscribe }} 플레이스홀더 사용.
 */

export interface EmailTemplateInput {
  title: string;
  content: string;      // HTML 또는 텍스트 (텍스트면 줄바꿈을 <br/>로 변환)
  excerpt?: string;     // 프리헤더/미리보기 텍스트
  blogUrl?: string;     // 블로그 원문 링크
  category?: string;
}

/**
 * 뉴스레터 HTML 템플릿 생성.
 *
 * - 600px 고정 폭 (이메일 클라이언트 호환)
 * - AI AppPro 브랜딩 (로고 텍스트, 브랜드 컬러)
 * - CTA 버튼: 블로그 원문 보기
 * - 하단: 구독 해지 링크 (Brevo {{ unsubscribe }})
 */
export function generateNewsletterHtml(input: EmailTemplateInput): string {
  const { title, content, excerpt, blogUrl, category } = input;

  // content가 HTML 태그를 포함하지 않으면 줄바꿈을 <br/>로 변환
  const isHtml = /<[a-z][\s\S]*>/i.test(content);
  const htmlContent = isHtml ? content : content.replace(/\n/g, '<br/>');

  const categoryBadge = category
    ? `<span style="display:inline-block;background:#e8f4fd;color:#0077cc;padding:4px 12px;border-radius:12px;font-size:12px;margin-bottom:16px;">${category}</span>`
    : '';

  const ctaButton = blogUrl
    ? `<div style="text-align:center;margin:32px 0;">
        <a href="${blogUrl}" style="display:inline-block;background:#0077cc;color:#ffffff;padding:14px 32px;border-radius:6px;text-decoration:none;font-weight:bold;font-size:16px;">블로그에서 전체 보기</a>
      </div>`
    : '';

  const preheader = excerpt
    ? `<div style="display:none;max-height:0;overflow:hidden;mso-hide:all;">${excerpt}</div>`
    : '';

  return `<!DOCTYPE html>
<html lang="ko">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>${title}</title>
</head>
<body style="margin:0;padding:0;background-color:#f5f5f5;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,'Helvetica Neue',Arial,sans-serif;">
  ${preheader}
  <table role="presentation" style="width:100%;border-collapse:collapse;">
    <tr>
      <td align="center" style="padding:24px 16px;">
        <table role="presentation" style="width:600px;max-width:100%;border-collapse:collapse;background:#ffffff;border-radius:8px;overflow:hidden;box-shadow:0 1px 3px rgba(0,0,0,0.1);">
          <!-- Header -->
          <tr>
            <td style="background:#0077cc;padding:24px 32px;text-align:center;">
              <h1 style="margin:0;color:#ffffff;font-size:20px;font-weight:700;letter-spacing:-0.5px;">AI AppPro</h1>
              <p style="margin:4px 0 0;color:#cce5ff;font-size:13px;">AI로 비즈니스를 한 단계 업그레이드</p>
            </td>
          </tr>
          <!-- Body -->
          <tr>
            <td style="padding:32px;">
              ${categoryBadge}
              <h2 style="margin:0 0 20px;color:#1a1a1a;font-size:22px;line-height:1.4;font-weight:700;">${title}</h2>
              <div style="color:#333333;font-size:16px;line-height:1.7;">
                ${htmlContent}
              </div>
              ${ctaButton}
            </td>
          </tr>
          <!-- Footer -->
          <tr>
            <td style="background:#f9f9f9;padding:20px 32px;border-top:1px solid #eeeeee;">
              <p style="margin:0;color:#888888;font-size:12px;line-height:1.5;text-align:center;">
                이 메일은 <a href="https://apppro.kr" style="color:#0077cc;text-decoration:none;">AI AppPro</a>에서 발송했습니다.<br/>
                더 이상 수신을 원하지 않으시면 <a href="{{ unsubscribe }}" style="color:#0077cc;text-decoration:none;">구독 해지</a>해 주세요.
              </p>
            </td>
          </tr>
        </table>
      </td>
    </tr>
  </table>
</body>
</html>`;
}

Step 2: publish-orchestrator.ts의 publishToBrevoChannel()에서 템플릿 사용

src/lib/publish-orchestrator.ts에서 현재 인라인 HTML 생성 코드 (line 327~343)를 generateNewsletterHtml() 호출로 교체.

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

import { generateNewsletterHtml } from './email-templates';

현재 코드 (line 327~343):

    // 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>`;
    }

변경 후:

    // HTML 이메일 템플릿 생성 (email-templates.ts)
    let htmlContent: string;
    try {
      const parsed = JSON.parse(contentBody);
      htmlContent = generateNewsletterHtml({
        title,
        content: parsed.content as string || contentBody,
        excerpt: parsed.excerpt as string || undefined,
        blogUrl: parsed.slug ? `https://apppro.kr/blog/posts/${parsed.slug}` : 'https://apppro.kr/blog',
        category: parsed.category as string || undefined,
      });
    } catch {
      htmlContent = generateNewsletterHtml({
        title,
        content: contentBody,
        blogUrl: 'https://apppro.kr/blog',
      });
    }

Step 3: TypeScript 컴파일 확인

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/email-templates.ts \
        projects/content-pipeline/src/lib/publish-orchestrator.ts
git pull --rebase origin main
git commit -m "feat(content-pipeline): add HTML email template + integrate into publishToBrevoChannel (Phase 2-B Task 13)"
git push origin main

부록 A: Brevo 폴링 Cron API (Task 9 활성화 시 추가)

Task 9의 runPollBrevo() 함수를 Vercel Cron으로 실행하려면 API 엔드포인트가 필요하다. Phase 2-C에서 /api/cron/poll-brevo 엔드포인트를 추가할 수 있다:

// src/app/api/cron/poll-brevo/route.ts (Phase 2-C)
import { NextResponse } from 'next/server';
import { runPollBrevo } from '../../../../pipeline/poll-brevo';
import { ensureSchema } from '../../../../lib/content-db';

export async function GET() {
  await ensureSchema();
  const result = await runPollBrevo();
  return NextResponse.json(result);
}
// vercel.json에 추가
{
  "crons": [
    { "path": "/api/cron/poll-brevo", "schedule": "0 */2 * * *" }
  ]
}

이 Cron 엔드포인트는 Phase 2-B 범위 밖. Task 9 승인 후 별도 구현 가능.


부록 B: Brevo GetEmailCampaignResponse 참조

Brevo SDK getEmailCampaign() 응답 (@getbrevo/brevo v3+):

// statistics.globalStats 필드 (GetCampaignStats 타입):
{
  sent: number;           // 발송 수
  delivered: number;      // 도달 수
  uniqueViews: number;    // 고유 오픈 수
  uniqueClicks: number;   // 고유 클릭 수
  opensRate: number;      // 오픈율 (%)
  unsubscriptions: number; // 수신 거부 수
  hardBounces: number;    // 하드 바운스
  softBounces: number;    // 소프트 바운스
  clickers: number;       // 총 클릭 수
  complaints: number;     // 스팸 신고 수
}

// 캠페인 status 값:
'draft' | 'sent' | 'archive' | 'queued' | 'suspended' | 'in_process'

작업 요약

Task설명파일신규/수정상태
10metrics TEXT 컬럼 추가content-db.ts수정대기
9Brevo 캠페인 통계 폴링brevo.ts + poll-brevo.ts (신규)수정+신규대기
12알림 조회 APIapi/pipeline/notifications/route.ts (신규)신규대기
13HTML 이메일 템플릿email-templates.ts (신규) + publish-orchestrator.ts신규+수정대기

의존성 체인: Task 10 → Task 9 (순차), Task 12/13 독립 (병렬 가능)

예상 소요: 약 2535분 (4개 Task, 각 510분)


리뷰 로그

[phase2b-plan-pl 초안 작성] 2026-02-25T22:30:00+09:00

  • 코드베이스 분석 8개 파일: content-db.ts, brevo.ts, publish-orchestrator.ts, channels.ts, stage-publish.ts, 기존 API routes 3종
  • Brevo SDK 타입 분석: GetEmailCampaignResponse, GetExtendedCampaignStats, GetCampaignStats — 실제 필드명(uniqueViews, opensRate 등) 확인
  • Task 10: content_distributions에 metrics TEXT 컬럼 ALTER TABLE + 인터페이스 업데이트
  • Task 9: brevo.ts에 getCampaignStats() 추가 + poll-brevo.ts 신규 — 1시간 미갱신 레코드만 폴링, LIMIT 10
  • Task 12: /api/pipeline/notifications GET — 기존 errors/logs route 패턴 준수 (WHERE 빌더, 페이지네이션)
  • Task 13: email-templates.ts 신규 — 600px 반응형 테이블 레이아웃, AI AppPro 브랜딩, Brevo {{ unsubscribe }} 플레이스홀더. publish-orchestrator.ts 인라인 HTML 교체
  • 부록: Cron 엔드포인트 참조 (Phase 2-C), Brevo SDK 타입 참조
plans/2026/02/25/content-orchestration-impl-phase2b.md