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 NULLANDsubtask_status = 'waiting_ceo' - 부모 task 제목:
tasks.parent_id → tasks.idself-join으로 조회 - task_comments: 기존
/api/tasks/[id]/comments패턴 참조 - 기존 라우트 패턴:
NextRequest → NextResponse.json({ data, ok: true })
엔드포인트 명세
| Method | Path | 설명 |
|---|---|---|
| GET | /api/musk/waiting | waiting_ceo subtask 목록 (부모 task 제목 포함) |
| PATCH | /api/musk/waiting/[id] | status 변경 (→ responded / in_progress / blocked / drop) |
| POST | /api/musk/waiting/[id]/comment | comment 추가 (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_KEY | kanban-dashboard/.env + Vercel 환경변수 | VP 인증 키 (임의 강력한 문자열) |
생성 방법:
openssl rand -hex 32 # 예시: 64자 hex 문자열
응답 형식 통일 (모든 엔드포인트)
성공:
{ "data": { ... }, "ok": true }
실패:
{ "error": "에러 메시지", "ok": false }
401 Unauthorized:
{ "error": "Unauthorized", "ok": false }
제약 사항 및 주의
- SQLite/Turso 문법:
integer(mode: 'timestamp_ms')—Date객체 직접 사용 가능 (Drizzle 자동 변환) - inArray 사용 주의: Turso 환경에서 대용량 inArray는 성능 이슈 가능. 이 플랜에서는 parentIds가 소량이므로 in-memory 필터 사용
- 자체 join 회피: SQLite self-join 대신 2-pass 조회 패턴 사용 (기존
/api/stats패턴과 동일) - MUSK_API_KEY 미설정 시:
undefined !== undefined→ false → 401 반환 (안전한 기본값) - 타입 호환:
subtaskStatus컬럼은SubtaskStatusunion type — PATCH에서 허용 값만 z.enum으로 제한
완료 기준 (Definition of Done)
-
src/lib/musk-auth.ts생성됨 -
/api/musk/waitingGET 정상 응답 (200) -
/api/musk/waiting/[id]PATCH 정상 응답 (200) -
/api/musk/waiting/[id]/commentPOST 정상 응답 (201) -
/api/musk/summaryGET 정상 응답 (200) - 인증 실패 시 401 응답
-
npm run check:types오류 0건 -
npm run build성공 - git commit + push 완료