← 목록으로
2026-03-03plans

문서 접근성 개선 시스템 Implementation Plan

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

Goal: docs/plans/ 의 모든 마크다운 파일을 kanban DB에 저장하고, content-orchestration에 공개 링크로 접근 가능한 /documents 웹 인터페이스를 구축한다.

Architecture: 로컬 마이그레이션 스크립트가 docs/plans/**/*.md 를 읽어 kanban DB documents 테이블에 upsert한다. content-orchestration(Next.js 16)에 /documents (목록)와 /documents/[id] (상세)를 추가하고 kanban DB에서 직접 읽어 렌더링한다. 인증 없이 공개 링크로 접근 가능하며, 새 문서 추가 시 npm run sync-docs 명령으로 수동 동기화한다.

Tech Stack: Next.js 16 App Router, @libsql/client (이미 설치됨), react-markdown + remark-gfm (이미 설치됨), @tailwindcss/typography (이미 설치됨), Turso kanban DB


환경 변수 참고

# content-orchestration/.env 에 추가 필요 (Vercel 환경변수도 추가)
KANBAN_DB_URL=libsql://kanban-migkjy.aws-ap-northeast-1.turso.io
KANBAN_DB_TOKEN=eyJhbGciOiJFZERTQSIsInR5cCI6IkpXVCJ9.eyJpYXQiOjE3NzE2ODE3MzgsImlkIjoiYTZlYzYxYjAtYjNkNC00YzVkLThhZjYtNTVkZTU4M2RhYTRmIiwicmlkIjoiZTQ3OGNiZGQtNGU1My00NTgzLWE4MzktMjY4YzU1ZTBlNTQ0In0.YIItPetx8vhqqFDfQen-PeXXFhF4g7elt9koDR5xHvGxikvmITAxTYf2QixYCq3PtyJhaR9S5JlWElF9eLoODQ

# 마이그레이션 스크립트 전용 (로컬 실행 시)
DOCS_ROOT=/Users/nbs22/(Claude)/(claude).projects/business-builder/docs

프로젝트 경로

/Users/nbs22/(Claude)/(claude).projects/business-builder/projects/content-orchestration/

Task 1: kanban DB 클라이언트 + documents 테이블 스키마

Files:

  • Create: src/lib/kanban-db.ts
  • Modify: .env (env 변수 추가)

Step 1: .env에 kanban DB 환경변수 추가

# content-orchestration/.env 에 아래 2줄 추가
KANBAN_DB_URL=libsql://kanban-migkjy.aws-ap-northeast-1.turso.io
KANBAN_DB_TOKEN=eyJhbGciOiJFZERTQSIsInR5cCI6IkpXVCJ9.eyJpYXQiOjE3NzE2ODE3MzgsImlkIjoiYTZlYzYxYjAtYjNkNC00YzVkLThhZjYtNTVkZTU4M2RhYTRmIiwicmlkIjoiZTQ3OGNiZGQtNGU1My00NTgzLWE4MzktMjY4YzU1ZTBlNTQ0In0.YIItPetx8vhqqFDfQen-PeXXFhF4g7elt9koDR5xHvGxikvmITAxTYf2QixYCq3PtyJhaR9S5JlWElF9eLoODQ

Step 2: src/lib/kanban-db.ts 생성

import { createClient } from '@libsql/client/web';

function getKanbanDb() {
  return createClient({
    url: process.env.KANBAN_DB_URL!,
    authToken: process.env.KANBAN_DB_TOKEN!,
  });
}

export interface Document {
  id: string;
  title: string;
  content: string;
  file_path: string;
  category: string | null;
  doc_date: string | null;
  created_at: number;
  updated_at: number;
}

export async function ensureDocumentsSchema() {
  const db = getKanbanDb();
  await db.execute(`
    CREATE TABLE IF NOT EXISTS documents (
      id TEXT PRIMARY KEY,
      title TEXT NOT NULL,
      content TEXT NOT NULL,
      file_path TEXT NOT NULL UNIQUE,
      category TEXT,
      doc_date TEXT,
      created_at INTEGER NOT NULL,
      updated_at INTEGER NOT NULL
    )
  `);
}

export async function getDocuments(category?: string): Promise<Document[]> {
  const db = getKanbanDb();
  const sql = category
    ? 'SELECT id, title, file_path, category, doc_date, created_at, updated_at FROM documents WHERE category = ? ORDER BY doc_date DESC, created_at DESC'
    : 'SELECT id, title, file_path, category, doc_date, created_at, updated_at FROM documents ORDER BY doc_date DESC, created_at DESC';
  const args = category ? [category] : [];
  const result = await db.execute({ sql, args });
  return result.rows.map((r) => ({
    id: r[0] as string,
    title: r[1] as string,
    content: '',
    file_path: r[2] as string,
    category: r[3] as string | null,
    doc_date: r[4] as string | null,
    created_at: r[5] as number,
    updated_at: r[6] as number,
  }));
}

export async function getDocument(id: string): Promise<Document | null> {
  const db = getKanbanDb();
  const result = await db.execute({
    sql: 'SELECT id, title, content, file_path, category, doc_date, created_at, updated_at FROM documents WHERE id = ?',
    args: [id],
  });
  if (result.rows.length === 0) return null;
  const r = result.rows[0];
  return {
    id: r[0] as string,
    title: r[1] as string,
    content: r[2] as string,
    file_path: r[3] as string,
    category: r[4] as string | null,
    doc_date: r[5] as string | null,
    created_at: r[6] as number,
    updated_at: r[7] as number,
  };
}

export async function upsertDocument(doc: Omit<Document, 'created_at' | 'updated_at'> & { created_at?: number }): Promise<void> {
  const db = getKanbanDb();
  const now = Date.now();
  await db.execute({
    sql: `INSERT INTO documents (id, title, content, file_path, category, doc_date, created_at, updated_at)
          VALUES (?, ?, ?, ?, ?, ?, ?, ?)
          ON CONFLICT(file_path) DO UPDATE SET
            title = excluded.title,
            content = excluded.content,
            category = excluded.category,
            doc_date = excluded.doc_date,
            updated_at = excluded.updated_at`,
    args: [doc.id, doc.title, doc.content, doc.file_path, doc.category ?? null, doc.doc_date ?? null, doc.created_at ?? now, now],
  });
}

export async function getCategories(): Promise<string[]> {
  const db = getKanbanDb();
  const result = await db.execute(
    'SELECT DISTINCT category FROM documents WHERE category IS NOT NULL ORDER BY category'
  );
  return result.rows.map((r) => r[0] as string);
}

Step 3: 빌드 확인

cd /Users/nbs22/(Claude)/(claude).projects/business-builder/projects/content-orchestration
npm run build 2>&1 | tail -20

Expected: 빌드 성공 (kanban-db.ts 타입 에러 없음)

Step 4: Commit

git add src/lib/kanban-db.ts .env
git commit -m "feat: add kanban DB client with documents schema"

Task 2: 마이그레이션 스크립트 (로컬 docs → kanban DB)

Files:

  • Create: scripts/migrate-docs.mjs
  • Modify: package.json (scripts에 sync-docs 추가)

Step 1: scripts/migrate-docs.mjs 생성

// scripts/migrate-docs.mjs
// 실행: node scripts/migrate-docs.mjs
// 필요 env: KANBAN_DB_URL, KANBAN_DB_TOKEN, DOCS_ROOT (기본값: business-builder/docs)

import { createClient } from '@libsql/client/web';
import { readdir, readFile, stat } from 'fs/promises';
import { join, relative, basename, extname } from 'path';
import { randomUUID } from 'crypto';
import { fileURLToPath } from 'url';
import { dirname } from 'path';

const __dirname = dirname(fileURLToPath(import.meta.url));

const DOCS_ROOT = process.env.DOCS_ROOT
  ?? join(__dirname, '..', '..', '..', '..', 'docs');

const db = createClient({
  url: process.env.KANBAN_DB_URL,
  authToken: process.env.KANBAN_DB_TOKEN,
});

// 파일 경로에서 카테고리/날짜 추출
// 예: docs/plans/2026/03/03/file.md → category=plans, doc_date=2026-03-03
function parsePathInfo(filePath) {
  const rel = relative(DOCS_ROOT, filePath);
  const parts = rel.split('/');

  let category = null;
  let doc_date = null;

  if (parts.length >= 1) category = parts[0]; // plans, reports, strategy 등

  // YYYY/MM/DD 패턴 찾기
  const dateMatch = rel.match(/(\d{4})\/(\d{2})\/(\d{2})/);
  if (dateMatch) {
    doc_date = `${dateMatch[1]}-${dateMatch[2]}-${dateMatch[3]}`;
  }

  return { category, doc_date };
}

// 마크다운에서 첫 번째 h1 제목 추출
function extractTitle(content, filePath) {
  const h1Match = content.match(/^#\s+(.+)$/m);
  if (h1Match) return h1Match[1].trim();
  // h1 없으면 파일명 사용
  return basename(filePath, extname(filePath))
    .replace(/-/g, ' ')
    .replace(/\b\w/g, (c) => c.toUpperCase());
}

// 디렉토리 재귀 탐색
async function* walkDir(dir) {
  const entries = await readdir(dir, { withFileTypes: true });
  for (const entry of entries) {
    const fullPath = join(dir, entry.name);
    if (entry.isDirectory()) {
      yield* walkDir(fullPath);
    } else if (entry.name.endsWith('.md')) {
      yield fullPath;
    }
  }
}

async function main() {
  // 테이블 생성
  await db.execute(`
    CREATE TABLE IF NOT EXISTS documents (
      id TEXT PRIMARY KEY,
      title TEXT NOT NULL,
      content TEXT NOT NULL,
      file_path TEXT NOT NULL UNIQUE,
      category TEXT,
      doc_date TEXT,
      created_at INTEGER NOT NULL,
      updated_at INTEGER NOT NULL
    )
  `);

  let count = 0;
  let errors = 0;

  for await (const filePath of walkDir(DOCS_ROOT)) {
    try {
      const content = await readFile(filePath, 'utf-8');
      const title = extractTitle(content, filePath);
      const { category, doc_date } = parsePathInfo(filePath);
      const relPath = relative(DOCS_ROOT, filePath);
      const now = Date.now();

      await db.execute({
        sql: `INSERT INTO documents (id, title, content, file_path, category, doc_date, created_at, updated_at)
              VALUES (?, ?, ?, ?, ?, ?, ?, ?)
              ON CONFLICT(file_path) DO UPDATE SET
                title = excluded.title,
                content = excluded.content,
                category = excluded.category,
                doc_date = excluded.doc_date,
                updated_at = excluded.updated_at`,
        args: [randomUUID().replace(/-/g, '').slice(0, 25), title, content, relPath, category, doc_date, now, now],
      });

      count++;
      process.stdout.write(`\r${count}개 처리 중...`);
    } catch (err) {
      console.error(`\n오류 (${filePath}):`, err.message);
      errors++;
    }
  }

  console.log(`\n\n완료: ${count}개 업서트, 오류 ${errors}개`);
  process.exit(0);
}

main().catch((err) => { console.error(err); process.exit(1); });

Step 2: package.json에 sync-docs 스크립트 추가

package.json"scripts" 섹션에 추가:

"sync-docs": "DOCS_ROOT=/Users/nbs22/(Claude)/(claude).projects/business-builder/docs node scripts/migrate-docs.mjs"

Step 3: 마이그레이션 실행 (로컬)

cd /Users/nbs22/(Claude)/(claude).projects/business-builder/projects/content-orchestration
KANBAN_DB_URL="libsql://kanban-migkjy.aws-ap-northeast-1.turso.io" \
KANBAN_DB_TOKEN="eyJhbGciOiJFZERTQSIsInR5cCI6IkpXVCJ9.eyJpYXQiOjE3NzE2ODE3MzgsImlkIjoiYTZlYzYxYjAtYjNkNC00YzVkLThhZjYtNTVkZTU4M2RhYTRmIiwicmlkIjoiZTQ3OGNiZGQtNGU1My00NTgzLWE4MzktMjY4YzU1ZTBlNTQ0In0.YIItPetx8vhqqFDfQen-PeXXFhF4g7elt9koDR5xHvGxikvmITAxTYf2QixYCq3PtyJhaR9S5JlWElF9eLoODQ" \
DOCS_ROOT="/Users/nbs22/(Claude)/(claude).projects/business-builder/docs" \
npm run sync-docs

Expected: 완료: 72개 업서트, 오류 0개 (또는 유사한 숫자)

Step 4: DB 확인

KANBAN_DB_URL="libsql://kanban-migkjy.aws-ap-northeast-1.turso.io" \
KANBAN_DB_TOKEN="eyJhbGciOiJFZERTQSIsInR5cCI6IkpXVCJ9.eyJpYXQiOjE3NzE2ODE3MzgsImlkIjoiYTZlYzYxYjAtYjNkNC00YzVkLThhZjYtNTVkZTU4M2RhYTRmIiwicmlkIjoiZTQ3OGNiZGQtNGU1My00NTgzLWE4MzktMjY4YzU1ZTBlNTQ0In0.YIItPetx8vhqqFDfQen-PeXXFhF4g7elt9koDR5xHvGxikvmITAxTYf2QixYCq3PtyJhaR9S5JlWElF9eLoODQ" \
node -e "
const { createClient } = await import('@libsql/client/web');
const db = createClient({ url: process.env.KANBAN_DB_URL, authToken: process.env.KANBAN_DB_TOKEN });
const r = await db.execute('SELECT COUNT(*) FROM documents');
console.log('총 문서 수:', r.rows[0][0]);
"

Expected: 총 문서 수: 72 이상

Step 5: Commit

git add scripts/migrate-docs.mjs package.json
git commit -m "feat: add docs migration script with npm run sync-docs"

Task 3: /documents 목록 페이지

Files:

  • Create: src/app/documents/page.tsx

Step 1: src/app/documents/page.tsx 생성

import Link from 'next/link';
import { getDocuments, getCategories, ensureDocumentsSchema } from '@/lib/kanban-db';

export const revalidate = 300;

const CATEGORY_LABELS: Record<string, string> = {
  plans: '플랜',
  reports: '보고서',
  strategy: '전략',
  docs: '문서',
};

export default async function DocumentsPage({
  searchParams,
}: {
  searchParams: Promise<{ category?: string }>;
}) {
  await ensureDocumentsSchema().catch(() => {});
  const { category } = await searchParams;

  const [documents, categories] = await Promise.all([
    getDocuments(category).catch(() => []),
    getCategories().catch(() => []),
  ]);

  return (
    <div className="min-h-screen bg-gray-50">
      {/* 헤더 */}
      <div className="bg-white border-b border-gray-200">
        <div className="max-w-5xl mx-auto px-4 py-6">
          <div className="flex items-center justify-between">
            <div>
              <Link href="/" className="text-sm text-blue-600 hover:underline mb-1 block">
                ← 대시보드
              </Link>
              <h1 className="text-2xl font-bold text-gray-900">문서 허브</h1>
              <p className="text-sm text-gray-500 mt-1">플랜·보고서·전략 문서 전체 {documents.length}건</p>
            </div>
          </div>

          {/* 카테고리 필터 */}
          <div className="flex gap-2 mt-4 flex-wrap">
            <Link
              href="/documents"
              className={`px-3 py-1 rounded-full text-sm font-medium transition-colors ${
                !category ? 'bg-blue-600 text-white' : 'bg-gray-100 text-gray-600 hover:bg-gray-200'
              }`}
            >
              전체
            </Link>
            {categories.map((cat) => (
              <Link
                key={cat}
                href={`/documents?category=${cat}`}
                className={`px-3 py-1 rounded-full text-sm font-medium transition-colors ${
                  category === cat ? 'bg-blue-600 text-white' : 'bg-gray-100 text-gray-600 hover:bg-gray-200'
                }`}
              >
                {CATEGORY_LABELS[cat] ?? cat}
              </Link>
            ))}
          </div>
        </div>
      </div>

      {/* 목록 */}
      <div className="max-w-5xl mx-auto px-4 py-6">
        {documents.length === 0 ? (
          <div className="text-center py-20 text-gray-400">문서가 없습니다.</div>
        ) : (
          <div className="space-y-2">
            {documents.map((doc) => (
              <Link
                key={doc.id}
                href={`/documents/${doc.id}`}
                className="flex items-center justify-between bg-white border border-gray-200 rounded-lg px-5 py-4 hover:border-blue-300 hover:shadow-sm transition-all group"
              >
                <div className="min-w-0">
                  <p className="font-medium text-gray-900 truncate group-hover:text-blue-600">
                    {doc.title}
                  </p>
                  <p className="text-xs text-gray-400 mt-0.5 truncate">{doc.file_path}</p>
                </div>
                <div className="flex items-center gap-3 ml-4 shrink-0">
                  {doc.doc_date && (
                    <span className="text-xs text-gray-400">{doc.doc_date}</span>
                  )}
                  {doc.category && (
                    <span className="text-xs bg-blue-50 text-blue-600 px-2 py-0.5 rounded-full">
                      {CATEGORY_LABELS[doc.category] ?? doc.category}
                    </span>
                  )}
                  <span className="text-gray-300">→</span>
                </div>
              </Link>
            ))}
          </div>
        )}
      </div>
    </div>
  );
}

Step 2: 로컬에서 확인

npm run dev

브라우저에서 http://localhost:3000/documents 접속.

  • 문서 목록이 표시되는지 확인
  • 카테고리 필터 클릭 시 필터링 되는지 확인

Step 3: Commit

git add src/app/documents/page.tsx
git commit -m "feat: add /documents list page with category filter"

Task 4: /documents/[id] 상세 페이지 (마크다운 렌더링)

Files:

  • Create: src/app/documents/[id]/page.tsx

Step 1: src/app/documents/[id]/page.tsx 생성

import { notFound } from 'next/navigation';
import Link from 'next/link';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import { getDocument } from '@/lib/kanban-db';

type Props = { params: Promise<{ id: string }> };

export default async function DocumentDetailPage({ params }: Props) {
  const { id } = await params;
  const doc = await getDocument(id).catch(() => null);

  if (!doc) notFound();

  return (
    <div className="min-h-screen bg-gray-50">
      {/* 헤더 */}
      <div className="bg-white border-b border-gray-200 sticky top-0 z-10">
        <div className="max-w-4xl mx-auto px-4 py-4 flex items-center justify-between">
          <Link href="/documents" className="text-sm text-blue-600 hover:underline">
            ← 목록으로
          </Link>
          <div className="flex items-center gap-3 text-xs text-gray-400">
            {doc.doc_date && <span>{doc.doc_date}</span>}
            {doc.category && (
              <span className="bg-blue-50 text-blue-600 px-2 py-0.5 rounded-full">
                {doc.category}
              </span>
            )}
          </div>
        </div>
      </div>

      {/* 본문 */}
      <div className="max-w-4xl mx-auto px-4 py-10">
        <article className="bg-white rounded-xl border border-gray-200 px-8 py-10 prose prose-gray max-w-none
          prose-headings:font-bold prose-h1:text-2xl prose-h2:text-xl
          prose-a:text-blue-600 prose-code:bg-gray-100 prose-code:rounded prose-code:px-1
          prose-pre:bg-gray-900 prose-pre:text-gray-100">
          <ReactMarkdown remarkPlugins={[remarkGfm]}>
            {doc.content}
          </ReactMarkdown>
        </article>

        <div className="mt-6 text-xs text-gray-400 text-center">
          {doc.file_path}
        </div>
      </div>
    </div>
  );
}

Step 2: 로컬 확인

브라우저에서 목록에서 문서 클릭 → 상세 페이지 마크다운 렌더링 확인.

  • 코드 블록, 테이블, 헤딩 스타일 확인
  • 뒤로가기 링크 동작 확인

Step 3: Commit

git add src/app/documents/[id]/page.tsx
git commit -m "feat: add /documents/[id] detail page with markdown rendering"

Task 5: 대시보드 홈에 Docs 링크 추가

Files:

  • Modify: src/app/page.tsx

Step 1: 홈 페이지에 Docs 링크 추가

src/app/page.tsx의 return 상단에 문서 허브 링크 추가. 기존 렌더 구조 확인 후, 헤더 또는 퀵링크 영역에 아래 추가:

<Link
  href="/documents"
  className="inline-flex items-center gap-2 bg-blue-50 text-blue-700 px-4 py-2 rounded-lg text-sm font-medium hover:bg-blue-100 transition-colors"
>
  📄 문서 허브
</Link>

위치: 기존 캠페인 목록 상단, <h2> 태그나 첫 번째 섹션 이전.

Step 2: 확인

npm run build 2>&1 | tail -5

Expected: ✓ Compiled successfully

Step 3: Commit

git add src/app/page.tsx
git commit -m "feat: add docs hub link to dashboard home"

Task 6: Vercel 환경변수 추가 + 배포

Step 1: Vercel 환경변수 추가

Vercel 대시보드 → content-orchestration 프로젝트 → Settings → Environment Variables에 추가:

  • KANBAN_DB_URL = libsql://kanban-migkjy.aws-ap-northeast-1.turso.io
  • KANBAN_DB_TOKEN = (Task 1의 토큰 값)

또는 Vercel CLI로:

vercel env add KANBAN_DB_URL production
vercel env add KANBAN_DB_TOKEN production

Step 2: Push

git push origin main

Vercel 자동 배포 대기 (약 2분).

Step 3: 배포 후 확인

open https://content-orchestration.vercel.app/documents

Expected:

  • 문서 목록 표시
  • 카테고리 필터 동작
  • 문서 클릭 → 마크다운 렌더링

Step 4: 최종 보고

cd /Users/nbs22/(Claude)/(claude).projects/business-builder
./scripts/vice-reply.sh "✅ 문서 접근성 시스템 배포 완료

URL: https://content-orchestration.vercel.app/documents

- 총 X개 문서 마이그레이션 완료
- 카테고리 필터: plans/reports/strategy
- 마크다운 렌더링 (react-markdown + GFM)
- 공개 링크 (인증 없음)
- 새 문서 추가 시: npm run sync-docs 실행" "docs-system-complete"

실행 후 문서 추가 방법

새로운 .md 파일이 docs/ 하위에 추가되면:

cd /Users/nbs22/(Claude)/(claude).projects/business-builder/projects/content-orchestration
npm run sync-docs

자동으로 kanban DB에 upsert → content-orchestration에서 즉시 확인 가능.

plans/2026/03/03/documents-access-system.md