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.ts의 ensureSchema() 함수 끝에 (기존 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 | 설명 | 파일 | 상태 |
|---|---|---|---|
| 4 | pipeline_notifications 테이블 생성 | content-db.ts | 대기 |
| 5 | 알림 생성 함수 5종 | notifications.ts (신규) | 대기 |
| 6 | 파이프라인 알림 연동 | stage-generate.ts, stage-publish.ts, self-healing.ts | 대기 |
| 7 | Brevo 예약 발송 | publish-orchestrator.ts | 대기 |
| 8 | Brevo 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 참조 코드