Content Workflow UI Implementation Plan
For Claude: REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
Goal: content-orchestration 대시보드에 draft→검수→승인→발행 워크플로우 UI 추가
Architecture:
- 새 라우트
/[project]/content추가 (콘텐츠 워크플로우 전용 페이지) - Server Actions로 상태 변경 (approve/reject/schedule)
- content_queue 테이블 활용 (기존 DB 스키마 그대로 사용)
Tech Stack: Next.js 15 App Router, Server Actions, Turso LibSQL, Tailwind CSS
DB 현황 (사전 확인 완료)
content_queue 테이블 현재 상태:
- blog.apppro.kr: 8편 draft (오늘 B2B 블로그)
- linkedin: 10편 draft (오늘 링크드인)
- NULL channel: 5편 (기존 draft 4 + approved 1)
content_queue 핵심 컬럼:
id, type, pillar, topic, title, status, channel, project,
content_body, approved_by, approved_at, rejected_reason,
created_at, updated_at, scheduled_at
Status flow: draft → review → approved → scheduled → published
Task 1: content-db.ts에 워크플로우 함수 추가
Files:
- Modify:
src/lib/content-db.ts
Step 1: ContentQueueItem 타입에 title, content_body 필드 추가
export interface ContentQueueItem {
id: string;
type: string;
pillar: string | null;
topic: string | null;
title: string | null; // 추가
content_body: string | null; // 추가
status: string;
priority: number;
result_id: string | null;
error_message: string | null;
created_at: number;
updated_at: number;
scheduled_at: number | null;
channel: string | null;
project: string | null;
approved_by: string | null; // 추가
approved_at: number | null; // 추가
rejected_reason: string | null; // 추가
}
Step 2: getContentQueueFull 함수 추가 (title 포함)
export async function getContentQueueFull(
db: ReturnType<typeof getContentDb>,
project?: string,
status?: string,
channel?: string
): Promise<ContentQueueItem[]> {
let query = `SELECT id, type, pillar, topic, title, content_body, status, priority,
channel, project, approved_by, approved_at, rejected_reason,
created_at, updated_at, scheduled_at
FROM content_queue`;
const conditions: string[] = [];
const args: string[] = [];
if (project && project !== 'all') {
conditions.push('project = ?');
args.push(project);
}
if (status) {
conditions.push('status = ?');
args.push(status);
}
if (channel) {
conditions.push('channel = ?');
args.push(channel);
}
if (conditions.length > 0) {
query += ' WHERE ' + conditions.join(' AND ');
}
query += ' ORDER BY created_at DESC LIMIT 100';
const result = await db.execute({ sql: query, args });
return result.rows as unknown as ContentQueueItem[];
}
Step 3: updateContentStatus 함수 추가
export async function updateContentStatus(
db: ReturnType<typeof getContentDb>,
id: string,
status: string,
options?: {
approved_by?: string;
rejected_reason?: string;
scheduled_at?: number;
}
): Promise<void> {
const now = Date.now();
let query = 'UPDATE content_queue SET status = ?, updated_at = ?';
const args: (string | number | null)[] = [status, now];
if (status === 'approved' && options?.approved_by) {
query += ', approved_by = ?, approved_at = ?';
args.push(options.approved_by, now);
}
if (status === 'rejected' && options?.rejected_reason) {
query += ', rejected_reason = ?';
args.push(options.rejected_reason);
}
if (status === 'scheduled' && options?.scheduled_at) {
query += ', scheduled_at = ?';
args.push(options.scheduled_at);
}
query += ' WHERE id = ?';
args.push(id);
await db.execute({ sql: query, args });
}
Step 4: 빌드 확인
cd projects/content-orchestration && npm run build
Expected: 빌드 성공
Step 5: Commit
git add src/lib/content-db.ts
git commit -m "feat: add workflow functions to content-db.ts (getContentQueueFull, updateContentStatus)"
Task 2: Server Actions 파일 생성
Files:
- Create:
src/app/actions/content.ts
Step 1: Server Action 파일 생성
'use server';
import { revalidatePath } from 'next/cache';
import { createClient } from '@libsql/client/web';
import { updateContentStatus } from '@/lib/content-db';
function getDb() {
return createClient({
url: process.env.CONTENT_OS_DB_URL!,
authToken: process.env.CONTENT_OS_DB_TOKEN!,
});
}
export async function approveContent(id: string, projectId: string) {
const db = getDb();
await updateContentStatus(db, id, 'approved', { approved_by: 'VP/CEO' });
revalidatePath(`/${projectId}/content`);
}
export async function rejectContent(id: string, projectId: string, reason: string) {
const db = getDb();
await updateContentStatus(db, id, 'rejected', { rejected_reason: reason });
revalidatePath(`/${projectId}/content`);
}
export async function moveToReview(id: string, projectId: string) {
const db = getDb();
await updateContentStatus(db, id, 'review');
revalidatePath(`/${projectId}/content`);
}
export async function scheduleContent(id: string, projectId: string, scheduledAt: number) {
const db = getDb();
await updateContentStatus(db, id, 'scheduled', { scheduled_at: scheduledAt });
revalidatePath(`/${projectId}/content`);
}
Step 2: 빌드 확인
npm run build
Step 3: Commit
git add src/app/actions/content.ts
git commit -m "feat: add content workflow server actions"
Task 3: /[project]/content 페이지 구현
Files:
- Create:
src/app/[project]/content/page.tsx
Step 1: content 페이지 구현
import { notFound } from 'next/navigation';
import { createClient } from '@libsql/client/web';
import { getProject } from '@/lib/projects';
import { getContentQueueFull } from '@/lib/content-db';
import { approveContent, rejectContent, moveToReview } from '@/app/actions/content';
export const revalidate = 0;
const STATUS_COLORS: Record<string, string> = {
draft: 'bg-gray-100 text-gray-700',
review: 'bg-yellow-100 text-yellow-800',
approved: 'bg-blue-100 text-blue-800',
scheduled: 'bg-purple-100 text-purple-800',
published: 'bg-green-100 text-green-800',
rejected: 'bg-red-100 text-red-800',
};
const CHANNEL_LABELS: Record<string, string> = {
'blog.apppro.kr': '✎ 블로그',
'linkedin': '◇ LinkedIn',
'instagram': '◎ 인스타',
'twitter': '✦ X/트위터',
};
const STATUS_TABS = ['all', 'draft', 'review', 'approved', 'scheduled', 'published'];
export default async function ContentWorkflowPage({
params,
searchParams,
}: {
params: { project: string };
searchParams: { status?: string; channel?: string };
}) {
const project = getProject(params.project);
if (!project) notFound();
const db = createClient({
url: process.env.CONTENT_OS_DB_URL!,
authToken: process.env.CONTENT_OS_DB_TOKEN!,
});
const statusFilter = searchParams.status;
const channelFilter = searchParams.channel;
const items = await getContentQueueFull(
db,
params.project,
statusFilter === 'all' ? undefined : statusFilter,
channelFilter
);
// 채널별 카운트
const channelCounts = items.reduce((acc, item) => {
const ch = item.channel || 'unknown';
acc[ch] = (acc[ch] || 0) + 1;
return acc;
}, {} as Record<string, number>);
// 상태별 카운트 (전체)
const allItems = await getContentQueueFull(db, params.project);
const statusCounts = allItems.reduce((acc, item) => {
acc[item.status] = (acc[item.status] || 0) + 1;
return acc;
}, {} as Record<string, number>);
return (
<div className="min-h-screen bg-gray-50">
<div className="max-w-6xl mx-auto px-4 py-8">
{/* 헤더 */}
<div className="mb-6">
<h1 className="text-2xl font-bold text-gray-900">
{project.name} · 콘텐츠 워크플로우
</h1>
<p className="text-sm text-gray-500 mt-1">
draft → 검수 → 승인 → 발행 파이프라인
</p>
</div>
{/* 상태 탭 */}
<div className="flex gap-2 mb-6 flex-wrap">
{STATUS_TABS.map((s) => {
const count = s === 'all' ? allItems.length : (statusCounts[s] || 0);
const isActive = (statusFilter || 'all') === s;
return (
<a
key={s}
href={`/${params.project}/content?status=${s}`}
className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
isActive
? 'bg-gray-900 text-white'
: 'bg-white text-gray-600 border border-gray-200 hover:bg-gray-50'
}`}
>
{s === 'all' ? '전체' : s}
{count > 0 && (
<span className={`ml-1.5 px-1.5 py-0.5 rounded-full text-xs ${
isActive ? 'bg-white text-gray-900' : 'bg-gray-100 text-gray-600'
}`}>
{count}
</span>
)}
</a>
);
})}
</div>
{/* 채널 필터 */}
<div className="flex gap-2 mb-4 text-xs">
<a href={`/${params.project}/content?status=${statusFilter || 'all'}`}
className="text-gray-500 hover:text-gray-900">전체 채널</a>
{Object.entries(CHANNEL_LABELS).map(([ch, label]) => (
<a key={ch}
href={`/${params.project}/content?status=${statusFilter || 'all'}&channel=${ch}`}
className="text-blue-600 hover:text-blue-800">
{label} ({channelCounts[ch] || 0})
</a>
))}
</div>
{/* 콘텐츠 목록 */}
<div className="space-y-3">
{items.length === 0 && (
<div className="bg-white rounded-lg border p-8 text-center text-gray-400">
해당 상태의 콘텐츠가 없습니다
</div>
)}
{items.map((item) => (
<div key={item.id} className="bg-white rounded-lg border border-gray-200 p-4">
<div className="flex items-start justify-between gap-4">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<span className="text-xs text-gray-400 uppercase font-mono">
{CHANNEL_LABELS[item.channel || ''] || item.channel || '-'}
</span>
<span className={`inline-flex px-2 py-0.5 rounded text-xs font-medium ${
STATUS_COLORS[item.status] || 'bg-gray-100 text-gray-600'
}`}>
{item.status}
</span>
{item.pillar && (
<span className="text-xs text-purple-600 bg-purple-50 px-2 py-0.5 rounded">
{item.pillar}
</span>
)}
</div>
<h3 className="text-sm font-medium text-gray-900 leading-snug">
{item.title || item.topic || '(제목 없음)'}
</h3>
{item.approved_by && (
<p className="text-xs text-gray-400 mt-1">승인: {item.approved_by}</p>
)}
{item.rejected_reason && (
<p className="text-xs text-red-500 mt-1">반려: {item.rejected_reason}</p>
)}
</div>
{/* 액션 버튼 */}
<div className="flex gap-2 shrink-0">
{item.status === 'draft' && (
<form action={async () => {
'use server';
await moveToReview(item.id, params.project);
}}>
<button type="submit"
className="px-3 py-1.5 text-xs font-medium text-yellow-700 bg-yellow-50 border border-yellow-200 rounded hover:bg-yellow-100 transition-colors">
검수 요청
</button>
</form>
)}
{(item.status === 'draft' || item.status === 'review') && (
<form action={async () => {
'use server';
await approveContent(item.id, params.project);
}}>
<button type="submit"
className="px-3 py-1.5 text-xs font-medium text-blue-700 bg-blue-50 border border-blue-200 rounded hover:bg-blue-100 transition-colors">
승인
</button>
</form>
)}
{item.status !== 'published' && item.status !== 'rejected' && (
<form action={async () => {
'use server';
await rejectContent(item.id, params.project, '검토 후 반려');
}}>
<button type="submit"
className="px-3 py-1.5 text-xs font-medium text-red-700 bg-red-50 border border-red-200 rounded hover:bg-red-100 transition-colors">
반려
</button>
</form>
)}
</div>
</div>
</div>
))}
</div>
</div>
</div>
);
}
Step 2: 빌드 확인
cd projects/content-orchestration && npm run build
Expected: 빌드 성공, /[project]/content 라우트 생성
Step 3: Commit
git add src/app/[project]/content/
git commit -m "feat: add content workflow page with approve/reject actions"
Task 4: 네비게이션에 Content 링크 추가
Files:
- Modify:
src/app/[project]/layout.tsx
Step 1: 레이아웃 파일 확인 후 Content 탭 추가
layout.tsx의 네비게이션 링크 목록에 아래 항목 추가:
{ href: `/${params.project}/content`, label: '콘텐츠' },
Step 2: 빌드 + 확인
npm run build
Step 3: Commit & Push
git add src/app/[project]/layout.tsx
git commit -m "feat: add content workflow nav link"
git pull --rebase origin main
git push origin main
Task 5: 배포 확인
Step 1: Vercel 배포 확인
vercel ls content-orchestration --scope junyoung-kims-projects | head -3
Step 2: 동작 확인 URL
- https://content-orchestration-junyoung-kims-projects.vercel.app/apppro/content
- draft 탭: 18편 표시 확인
- 승인 버튼 클릭 후 approved 탭으로 이동 확인
완료 기준
- /apppro/content 페이지 접속 가능
- 오늘 18편 draft 목록 표시 (블로그 8 + LinkedIn 10)
- 상태별 탭 필터 동작
- 채널별 필터 동작
- 승인 버튼 → approved 상태 변경
- 반려 버튼 → rejected 상태 변경
- main 브랜치 push 완료