← 목록으로
2026-03-01plans

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: draftreviewapprovedscheduledpublished


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


완료 기준

  • /apppro/content 페이지 접속 가능
  • 오늘 18편 draft 목록 표시 (블로그 8 + LinkedIn 10)
  • 상태별 탭 필터 동작
  • 채널별 필터 동작
  • 승인 버튼 → approved 상태 변경
  • 반려 버튼 → rejected 상태 변경
  • main 브랜치 push 완료
plans/2026/03/01/content-workflow-ui.md