← 목록으로
2026-02-26plans

title: 머스크 VP용 칸반 API 구현 플랜 date: 2026-02-26 type: implementation-plan layer: L2 status: draft tags: [kanban, musk-api, waiting-ceo, status-management] author: musk-api-plan-pl project: kanban-dashboard

머스크 VP용 칸반 API 구현 플랜

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

Goal: 머스크 VP가 waiting_ceo subtask를 API로 조회/status변경/코멘트 처리할 수 있는 엔드포인트 제작

Architecture: kanban-dashboard에 /api/musk/ 네임스페이스 추가. MUSK_API_KEY 헤더 인증. 기존 Turso DB 재사용.

Tech Stack: Next.js 15 App Router, TypeScript, Drizzle ORM, Turso (LibSQL/SQLite)


배경 및 목적

머스크 VP가 OpenClaw(tmux 환경)에서 칸반 DB의 waiting_ceo subtask를 직접 조회하고 status를 변경할 수 있어야 한다. 기존 /api/tasks 엔드포인트는 인증 없이 노출되어 있어 VP 전용 네임스페이스와 인증 레이어가 필요하다.

현재 상태 분석:

  • DB: Turso (LibSQL/SQLite) — libsql://kanban-migkjy.aws-ap-northeast-1.turso.io
  • ORM: Drizzle ORM (@/libs/DB, @/models/Schema)
  • subtask: parent_id IS NOT NULL AND subtask_status = 'waiting_ceo'
  • 부모 task 제목: tasks.parent_id → tasks.id self-join으로 조회
  • task_comments: 기존 /api/tasks/[id]/comments 패턴 참조
  • 기존 라우트 패턴: NextRequest → NextResponse.json({ data, ok: true })

엔드포인트 명세

MethodPath설명
GET/api/musk/waitingwaiting_ceo subtask 목록 (부모 task 제목 포함)
PATCH/api/musk/waiting/[id]status 변경 (→ responded / in_progress / blocked / drop)
POST/api/musk/waiting/[id]/commentcomment 추가 (author: 'musk')
GET/api/musk/summary통계 (총 waiting 수, 가장 오래된 항목, status별 count)

인증: 모든 엔드포인트에 x-musk-api-key 헤더 필수. 환경변수 MUSK_API_KEY와 비교.


파일 구조

projects/kanban-dashboard/src/
├── lib/
│   └── musk-auth.ts                           ← Task 1 (신규)
└── app/api/musk/
    ├── waiting/
    │   ├── route.ts                            ← Task 2 (GET 목록)
    │   └── [id]/
    │       ├── route.ts                        ← Task 3 (PATCH status)
    │       └── comment/
    │           └── route.ts                    ← Task 4 (POST comment)
    └── summary/
        └── route.ts                            ← Task 5 (GET 통계)

Task 1: src/lib/musk-auth.ts 생성

파일: /Users/nbs22/(Claude)/(claude).projects/business-builder/projects/kanban-dashboard/src/lib/musk-auth.ts

import type { NextRequest } from 'next/server';
import { NextResponse } from 'next/server';

/**
 * 머스크 VP API 인증 유틸
 * x-musk-api-key 헤더가 MUSK_API_KEY 환경변수와 일치하는지 검증
 * @returns NextResponse(401) if unauthorized, null if authorized
 */
export function checkMuskAuth(req: NextRequest): NextResponse | null {
  const key = req.headers.get('x-musk-api-key');
  if (!key || key !== process.env.MUSK_API_KEY) {
    return NextResponse.json({ error: 'Unauthorized', ok: false }, { status: 401 });
  }
  return null;
}

검증:

cd /Users/nbs22/'(Claude)'/'(claude)'.projects/business-builder/projects/kanban-dashboard
npx tsc --noEmit --skipLibCheck 2>&1 | head -20

Task 2: src/app/api/musk/waiting/route.ts — GET (목록)

파일: /Users/nbs22/(Claude)/(claude).projects/business-builder/projects/kanban-dashboard/src/app/api/musk/waiting/route.ts

waiting_ceo subtask 목록을 부모 task 제목과 함께 반환. self-join으로 부모 title 조회.

import type { NextRequest } from 'next/server';
import { and, asc, eq, isNotNull, isNull } from 'drizzle-orm';
import { NextResponse } from 'next/server';
import { db } from '@/libs/DB';
import { tasks } from '@/models/Schema';
import { checkMuskAuth } from '@/lib/musk-auth';

// GET /api/musk/waiting — waiting_ceo subtask 목록 (부모 task 제목 포함)
export async function GET(request: NextRequest) {
  const authError = checkMuskAuth(request);
  if (authError) return authError;

  try {
    // waiting_ceo subtask 조회 (parent_id IS NOT NULL)
    const subtasks = await db
      .select({
        id: tasks.id,
        title: tasks.title,
        description: tasks.description,
        subtaskStatus: tasks.subtaskStatus,
        urgency: tasks.urgency,
        importance: tasks.importance,
        assignee: tasks.assignee,
        parentId: tasks.parentId,
        createdAt: tasks.createdAt,
        updatedAt: tasks.updatedAt,
        dueDate: tasks.dueDate,
        goal: tasks.goal,
        labels: tasks.labels,
      })
      .from(tasks)
      .where(
        and(
          isNotNull(tasks.parentId),
          eq(tasks.subtaskStatus, 'waiting_ceo'),
          isNull(tasks.deletedAt),
        ),
      )
      .orderBy(asc(tasks.createdAt));

    // 부모 task 제목 조회 (별도 쿼리 — SQLite self-join 제한 회피)
    const parentIds = [...new Set(subtasks.map(s => s.parentId).filter(Boolean))] as string[];

    const parentTasks = parentIds.length > 0
      ? await db
          .select({ id: tasks.id, title: tasks.title, status: tasks.status })
          .from(tasks)
          .where(and(
            // SQLite inArray 대체: OR 조건으로 처리
            // parentIds가 적으므로 직접 필터
            isNull(tasks.deletedAt),
          ))
          .then(all => all.filter(t => parentIds.includes(t.id)))
      : [];

    const parentMap = Object.fromEntries(parentTasks.map(p => [p.id, p]));

    const result = subtasks.map(s => ({
      ...s,
      parentTask: s.parentId ? parentMap[s.parentId] ?? null : null,
    }));

    return NextResponse.json({
      data: { subtasks: result, totalCount: result.length },
      ok: true,
    });
  } catch (error) {
    return NextResponse.json(
      { error: error instanceof Error ? error.message : 'Failed to fetch waiting tasks', ok: false },
      { status: 500 },
    );
  }
}

검증:

# 개발 서버 기동 후 테스트
curl -s -H "x-musk-api-key: $MUSK_API_KEY" http://localhost:5555/api/musk/waiting | jq '.ok'

Task 3: src/app/api/musk/waiting/[id]/route.ts — PATCH (status 변경)

파일: /Users/nbs22/(Claude)/(claude).projects/business-builder/projects/kanban-dashboard/src/app/api/musk/waiting/[id]/route.ts

subtask_status를 responded, in_progress, blocked, drop 중 하나로 변경.

import type { NextRequest } from 'next/server';
import { and, eq, isNull } from 'drizzle-orm';
import { NextResponse } from 'next/server';
import { z } from 'zod';
import { db } from '@/libs/DB';
import { tasks } from '@/models/Schema';
import { checkMuskAuth } from '@/lib/musk-auth';

const allowedStatuses = ['responded', 'in_progress', 'blocked', 'drop'] as const;

const patchSchema = z.object({
  subtaskStatus: z.enum(allowedStatuses),
  reason: z.string().optional(),
});

// PATCH /api/musk/waiting/[id] — subtask status 변경
export async function PATCH(
  request: NextRequest,
  { params }: { params: Promise<{ id: string }> },
) {
  const authError = checkMuskAuth(request);
  if (authError) return authError;

  try {
    const { id } = await params;
    const body = await request.json();
    const validated = patchSchema.parse(body);

    // subtask 존재 확인 (parent_id IS NOT NULL → subtask)
    const [existing] = await db
      .select({ id: tasks.id, subtaskStatus: tasks.subtaskStatus, parentId: tasks.parentId })
      .from(tasks)
      .where(and(eq(tasks.id, id), isNull(tasks.deletedAt)));

    if (!existing) {
      return NextResponse.json({ error: 'Subtask not found', ok: false }, { status: 404 });
    }

    if (!existing.parentId) {
      return NextResponse.json(
        { error: 'Target is a top-level task, not a subtask', ok: false },
        { status: 400 },
      );
    }

    const [updated] = await db
      .update(tasks)
      .set({
        subtaskStatus: validated.subtaskStatus,
        updatedAt: new Date(),
      })
      .where(eq(tasks.id, id))
      .returning({
        id: tasks.id,
        title: tasks.title,
        subtaskStatus: tasks.subtaskStatus,
        parentId: tasks.parentId,
        updatedAt: tasks.updatedAt,
      });

    return NextResponse.json({ data: updated, ok: true });
  } catch (error) {
    if (error instanceof Error && error.name === 'ZodError') {
      return NextResponse.json(
        { error: 'Validation failed: subtaskStatus must be one of responded/in_progress/blocked/drop', ok: false },
        { status: 400 },
      );
    }
    return NextResponse.json(
      { error: error instanceof Error ? error.message : 'Failed to update status', ok: false },
      { status: 500 },
    );
  }
}

검증:

# responded로 변경 테스트
curl -s -X PATCH \
  -H "x-musk-api-key: $MUSK_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"subtaskStatus":"responded"}' \
  http://localhost:5555/api/musk/waiting/{실제-subtask-uuid} | jq '.ok'

Task 4: src/app/api/musk/waiting/[id]/comment/route.ts — POST (comment)

파일: /Users/nbs22/(Claude)/(claude).projects/business-builder/projects/kanban-dashboard/src/app/api/musk/waiting/[id]/comment/route.ts

task_comments 테이블에 comment 추가. author는 'musk' 고정.

import type { NextRequest } from 'next/server';
import { and, eq, isNull } from 'drizzle-orm';
import { NextResponse } from 'next/server';
import { z } from 'zod';
import { db } from '@/libs/DB';
import { taskComments, tasks } from '@/models/Schema';
import { checkMuskAuth } from '@/lib/musk-auth';

const commentSchema = z.object({
  content: z.string().min(1, 'Content is required'),
  type: z.string().default('comment'),
  context: z.string().nullable().optional(),
  decision: z.string().nullable().optional(),
  reason: z.string().nullable().optional(),
  replyToId: z.string().uuid().nullable().optional(),
});

// POST /api/musk/waiting/[id]/comment — 머스크 VP 코멘트 추가
export async function POST(
  request: NextRequest,
  { params }: { params: Promise<{ id: string }> },
) {
  const authError = checkMuskAuth(request);
  if (authError) return authError;

  try {
    const { id } = await params;
    const body = await request.json();
    const validated = commentSchema.parse(body);

    // 대상 task/subtask 존재 확인
    const [existing] = await db
      .select({ id: tasks.id })
      .from(tasks)
      .where(and(eq(tasks.id, id), isNull(tasks.deletedAt)));

    if (!existing) {
      return NextResponse.json({ error: 'Task not found', ok: false }, { status: 404 });
    }

    const [comment] = await db
      .insert(taskComments)
      .values({
        taskId: id,
        type: validated.type,
        content: validated.content,
        context: validated.context ?? null,
        decision: validated.decision ?? null,
        reason: validated.reason ?? null,
        author: 'musk',  // VP 고정
        processingStatus: 'pending',
        ...(validated.replyToId ? { replyToId: validated.replyToId } : {}),
      })
      .returning();

    return NextResponse.json({ data: comment, ok: true }, { status: 201 });
  } catch (error) {
    if (error instanceof Error && error.name === 'ZodError') {
      return NextResponse.json({ error: 'Validation failed', details: error, ok: false }, { status: 400 });
    }
    return NextResponse.json(
      { error: error instanceof Error ? error.message : 'Failed to create comment', ok: false },
      { status: 500 },
    );
  }
}

검증:

curl -s -X POST \
  -H "x-musk-api-key: $MUSK_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"content":"확인했습니다. 진행하세요."}' \
  http://localhost:5555/api/musk/waiting/{subtask-uuid}/comment | jq '.ok'

Task 5: src/app/api/musk/summary/route.ts — GET (통계)

파일: /Users/nbs22/(Claude)/(claude).projects/business-builder/projects/kanban-dashboard/src/app/api/musk/summary/route.ts

전체 통계: waiting_ceo 총 수, 가장 오래된 항목, subtask_status별 count.

import type { NextRequest } from 'next/server';
import { and, asc, eq, isNotNull, isNull, sql } from 'drizzle-orm';
import { NextResponse } from 'next/server';
import { db } from '@/libs/DB';
import { tasks } from '@/models/Schema';
import { checkMuskAuth } from '@/lib/musk-auth';

// GET /api/musk/summary — 통계 (총 waiting 수, 가장 오래된 항목, status별 count)
export async function GET(request: NextRequest) {
  const authError = checkMuskAuth(request);
  if (authError) return authError;

  try {
    // subtask_status별 count (삭제되지 않은 subtask만)
    const statusCounts = await db
      .select({
        subtaskStatus: tasks.subtaskStatus,
        count: sql<number>`COUNT(*)`,
      })
      .from(tasks)
      .where(and(isNotNull(tasks.parentId), isNull(tasks.deletedAt)))
      .groupBy(tasks.subtaskStatus);

    // waiting_ceo 총 수
    const [waitingResult] = await db
      .select({ count: sql<number>`COUNT(*)` })
      .from(tasks)
      .where(
        and(
          isNotNull(tasks.parentId),
          eq(tasks.subtaskStatus, 'waiting_ceo'),
          isNull(tasks.deletedAt),
        ),
      );

    const waitingCount = Number(waitingResult?.count ?? 0);

    // 가장 오래된 waiting_ceo 항목
    const [oldest] = await db
      .select({
        id: tasks.id,
        title: tasks.title,
        parentId: tasks.parentId,
        createdAt: tasks.createdAt,
        dueDate: tasks.dueDate,
      })
      .from(tasks)
      .where(
        and(
          isNotNull(tasks.parentId),
          eq(tasks.subtaskStatus, 'waiting_ceo'),
          isNull(tasks.deletedAt),
        ),
      )
      .orderBy(asc(tasks.createdAt))
      .limit(1);

    // 부모 task 제목 조회 (oldest가 있을 때만)
    let oldestWithParent = null;
    if (oldest?.parentId) {
      const [parent] = await db
        .select({ id: tasks.id, title: tasks.title })
        .from(tasks)
        .where(eq(tasks.id, oldest.parentId));

      oldestWithParent = {
        ...oldest,
        parentTask: parent ?? null,
        waitingDaysMs: oldest.createdAt ? Date.now() - oldest.createdAt.getTime() : null,
      };
    }

    return NextResponse.json({
      data: {
        waitingCount,
        oldestWaiting: oldestWithParent,
        statusCounts: Object.fromEntries(
          statusCounts.map(r => [r.subtaskStatus ?? 'null', Number(r.count)]),
        ),
        generatedAt: new Date().toISOString(),
      },
      ok: true,
    });
  } catch (error) {
    return NextResponse.json(
      { error: error instanceof Error ? error.message : 'Failed to fetch summary', ok: false },
      { status: 500 },
    );
  }
}

검증:

curl -s -H "x-musk-api-key: $MUSK_API_KEY" http://localhost:5555/api/musk/summary | jq '.data.waitingCount'

Task 6: 타입체크 + 빌드 검증

cd /Users/nbs22/'(Claude)'/'(claude)'.projects/business-builder/projects/kanban-dashboard

# 타입체크
npm run check:types

# 빌드 (성공 여부 확인)
npm run build

통과 기준:

  • npm run check:types 오류 0건
  • npm run build 성공 (exit code 0)

Task 7: 커밋 + 푸시

cd /Users/nbs22/'(Claude)'/'(claude)'.projects/business-builder/projects/kanban-dashboard

git add \
  src/lib/musk-auth.ts \
  src/app/api/musk/waiting/route.ts \
  src/app/api/musk/waiting/\[id\]/route.ts \
  src/app/api/musk/waiting/\[id\]/comment/route.ts \
  src/app/api/musk/summary/route.ts

git commit -m "feat: 머스크 VP용 칸반 API /api/musk/ 네임스페이스 구현 (5 endpoints)"

git pull --rebase origin main && git push origin main

환경 변수 설정 필요 (CEO 액션)

변수위치설명
MUSK_API_KEYkanban-dashboard/.env + Vercel 환경변수VP 인증 키 (임의 강력한 문자열)

생성 방법:

openssl rand -hex 32  # 예시: 64자 hex 문자열

응답 형식 통일 (모든 엔드포인트)

성공:

{ "data": { ... }, "ok": true }

실패:

{ "error": "에러 메시지", "ok": false }

401 Unauthorized:

{ "error": "Unauthorized", "ok": false }

제약 사항 및 주의

  1. SQLite/Turso 문법: integer(mode: 'timestamp_ms')Date 객체 직접 사용 가능 (Drizzle 자동 변환)
  2. inArray 사용 주의: Turso 환경에서 대용량 inArray는 성능 이슈 가능. 이 플랜에서는 parentIds가 소량이므로 in-memory 필터 사용
  3. 자체 join 회피: SQLite self-join 대신 2-pass 조회 패턴 사용 (기존 /api/stats 패턴과 동일)
  4. MUSK_API_KEY 미설정 시: undefined !== undefined → false → 401 반환 (안전한 기본값)
  5. 타입 호환: subtaskStatus 컬럼은 SubtaskStatus union type — PATCH에서 허용 값만 z.enum으로 제한

완료 기준 (Definition of Done)

  • src/lib/musk-auth.ts 생성됨
  • /api/musk/waiting GET 정상 응답 (200)
  • /api/musk/waiting/[id] PATCH 정상 응답 (200)
  • /api/musk/waiting/[id]/comment POST 정상 응답 (201)
  • /api/musk/summary GET 정상 응답 (200)
  • 인증 실패 시 401 응답
  • npm run check:types 오류 0건
  • npm run build 성공
  • git commit + push 완료
plans/2026/02/26/musk-kanban-api.md