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.ts의 ContentDistribution 인터페이스 (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.ts의 ensureSchema() 함수 내, 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 | 설명 | 파일 | 신규/수정 | 상태 |
|---|---|---|---|---|
| 10 | metrics TEXT 컬럼 추가 | content-db.ts | 수정 | 대기 |
| 9 | Brevo 캠페인 통계 폴링 | brevo.ts + poll-brevo.ts (신규) | 수정+신규 | 대기 |
| 12 | 알림 조회 API | api/pipeline/notifications/route.ts (신규) | 신규 | 대기 |
| 13 | HTML 이메일 템플릿 | 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 타입 참조