[オプション] CRUD自動生成(検証・認可・本番対応)
Generates production-ready CRUD APIs with validation, authentication, and tests for specified entities.
/plugin marketplace add Chachamaru127/claude-code-harness/plugin install claude-code-harness@claude-code-harness-marketplaceoptional/指定したエンティティ(テーブル)のCRUD機能を、本番環境で使えるレベルで自動生成します。
/crud tasks特徴:
このコマンドは以下のスキルを Skill ツールで明示的に呼び出すこと:
| スキル | 用途 | 呼び出しタイミング |
|---|---|---|
impl | 実装(親スキル) | CRUD 機能実装時 |
verify | 検証(親スキル) | 実装後の検証時 |
呼び出し方法:
Skill ツールを使用:
skill: "claude-code-harness:impl" # CRUD 機能実装
skill: "claude-code-harness:verify" # ビルド検証
子スキル(自動ルーティング):
work-impl-feature - CRUD機能実装verify-build - ビルド検証core-diff-aware-editing - 差分を考慮した編集⚠️ 重要: スキルを呼び出さずに進めると usage 統計に記録されません。必ず Skill ツールで呼び出してください。
/crud tasks
→ tasks テーブルのCRUD機能を生成
ユーザーの入力を確認。入力がない場合は質問:
🎯 どのエンティティ(テーブル)のCRUDを作りますか?
例:
tasks- タスク管理posts- ブログ記事products- 商品bookings- 予約単数形でも複数形でもOKです!
回答を待つ
📋 以下のフィールドで良いですか?
// 例: tasks テーブル { id: string (UUID, 自動生成) title: string (必須) description: string (任意) status: 'todo' | 'in_progress' | 'done' (デフォルト: 'todo') priority: 'low' | 'medium' | 'high' (デフォルト: 'medium') due_date: Date (任意) user_id: string (外部キー, 自動設定) created_at: Date (自動生成) updated_at: Date (自動更新) }変更したい場合は教えてください。 例: 「assignee_idフィールドを追加して」
回答を待つ(または「OK」で進む)
🔗 他のテーブルとのリレーションはありますか?
例:
- 「tasksは1つのprojectに属する」(多対1)
- 「tasksは複数のtagsを持つ」(多対多)
ない場合は「なし」と答えてください。
回答を待つ
以下のファイルを自動生成します:
prisma/schema.prisma)model Task {
id String @id @default(uuid())
title String
description String?
status TaskStatus @default(TODO)
priority TaskPriority @default(MEDIUM)
due_date DateTime?
user_id String
user User @relation(fields: [user_id], references: [id], onDelete: Cascade)
created_at DateTime @default(now())
updated_at DateTime @updatedAt
@@index([user_id])
@@index([status])
@@index([due_date])
}
enum TaskStatus {
TODO
IN_PROGRESS
DONE
}
enum TaskPriority {
LOW
MEDIUM
HIGH
}
lib/validations/task.ts)import { z } from 'zod'
export const createTaskSchema = z.object({
title: z.string().min(1, '必須項目です').max(100, '100文字以内で入力してください'),
description: z.string().max(1000, '1000文字以内で入力してください').optional(),
status: z.enum(['todo', 'in_progress', 'done']).default('todo'),
priority: z.enum(['low', 'medium', 'high']).default('medium'),
due_date: z.string().datetime().optional(),
})
export const updateTaskSchema = createTaskSchema.partial()
export const taskQuerySchema = z.object({
page: z.coerce.number().int().positive().default(1),
limit: z.coerce.number().int().positive().max(100).default(20),
status: z.enum(['todo', 'in_progress', 'done']).optional(),
priority: z.enum(['low', 'medium', 'high']).optional(),
search: z.string().optional(),
sort_by: z.enum(['created_at', 'due_date', 'priority']).default('created_at'),
sort_order: z.enum(['asc', 'desc']).default('desc'),
})
export type CreateTaskInput = z.infer<typeof createTaskSchema>
export type UpdateTaskInput = z.infer<typeof updateTaskSchema>
export type TaskQuery = z.infer<typeof taskQuerySchema>
app/api/tasks/route.ts)import { NextRequest, NextResponse } from 'next/server'
import { auth } from '@clerk/nextjs/server'
import { prisma } from '@/lib/prisma'
import { createTaskSchema, taskQuerySchema } from '@/lib/validations/task'
// GET /api/tasks - タスク一覧取得(ページネーション、検索、フィルタ対応)
export async function GET(req: NextRequest) {
try {
const { userId } = await auth()
if (!userId) {
return NextResponse.json({ error: '認証が必要です' }, { status: 401 })
}
const { searchParams } = new URL(req.url)
const query = taskQuerySchema.parse(Object.fromEntries(searchParams))
const where = {
user_id: userId,
...(query.status && { status: query.status }),
...(query.priority && { priority: query.priority }),
...(query.search && {
OR: [
{ title: { contains: query.search, mode: 'insensitive' } },
{ description: { contains: query.search, mode: 'insensitive' } },
],
}),
}
const [tasks, total] = await Promise.all([
prisma.task.findMany({
where,
orderBy: { [query.sort_by]: query.sort_order },
skip: (query.page - 1) * query.limit,
take: query.limit,
}),
prisma.task.count({ where }),
])
return NextResponse.json({
data: tasks,
meta: {
page: query.page,
limit: query.limit,
total,
total_pages: Math.ceil(total / query.limit),
},
})
} catch (error) {
console.error('GET /api/tasks error:', error)
return NextResponse.json({ error: 'サーバーエラー' }, { status: 500 })
}
}
// POST /api/tasks - タスク作成
export async function POST(req: NextRequest) {
try {
const { userId } = await auth()
if (!userId) {
return NextResponse.json({ error: '認証が必要です' }, { status: 401 })
}
const body = await req.json()
const data = createTaskSchema.parse(body)
const task = await prisma.task.create({
data: {
...data,
user_id: userId,
},
})
return NextResponse.json(task, { status: 201 })
} catch (error) {
if (error instanceof z.ZodError) {
return NextResponse.json({ error: error.errors }, { status: 400 })
}
console.error('POST /api/tasks error:', error)
return NextResponse.json({ error: 'サーバーエラー' }, { status: 500 })
}
}
app/api/tasks/[id]/route.ts)import { NextRequest, NextResponse } from 'next/server'
import { auth } from '@clerk/nextjs/server'
import { prisma } from '@/lib/prisma'
import { updateTaskSchema } from '@/lib/validations/task'
// GET /api/tasks/:id - タスク詳細取得
export async function GET(
req: NextRequest,
{ params }: { params: { id: string } }
) {
try {
const { userId } = await auth()
if (!userId) {
return NextResponse.json({ error: '認証が必要です' }, { status: 401 })
}
const task = await prisma.task.findUnique({
where: { id: params.id },
})
if (!task) {
return NextResponse.json({ error: 'タスクが見つかりません' }, { status: 404 })
}
// 認可: 自分のタスクのみ取得可能
if (task.user_id !== userId) {
return NextResponse.json({ error: 'アクセス権限がありません' }, { status: 403 })
}
return NextResponse.json(task)
} catch (error) {
console.error(`GET /api/tasks/${params.id} error:`, error)
return NextResponse.json({ error: 'サーバーエラー' }, { status: 500 })
}
}
// PATCH /api/tasks/:id - タスク更新
export async function PATCH(
req: NextRequest,
{ params }: { params: { id: string } }
) {
try {
const { userId } = await auth()
if (!userId) {
return NextResponse.json({ error: '認証が必要です' }, { status: 401 })
}
const existingTask = await prisma.task.findUnique({
where: { id: params.id },
})
if (!existingTask) {
return NextResponse.json({ error: 'タスクが見つかりません' }, { status: 404 })
}
// 認可: 自分のタスクのみ更新可能
if (existingTask.user_id !== userId) {
return NextResponse.json({ error: 'アクセス権限がありません' }, { status: 403 })
}
const body = await req.json()
const data = updateTaskSchema.parse(body)
const task = await prisma.task.update({
where: { id: params.id },
data,
})
return NextResponse.json(task)
} catch (error) {
if (error instanceof z.ZodError) {
return NextResponse.json({ error: error.errors }, { status: 400 })
}
console.error(`PATCH /api/tasks/${params.id} error:`, error)
return NextResponse.json({ error: 'サーバーエラー' }, { status: 500 })
}
}
// DELETE /api/tasks/:id - タスク削除
export async function DELETE(
req: NextRequest,
{ params }: { params: { id: string } }
) {
try {
const { userId } = await auth()
if (!userId) {
return NextResponse.json({ error: '認証が必要です' }, { status: 401 })
}
const existingTask = await prisma.task.findUnique({
where: { id: params.id },
})
if (!existingTask) {
return NextResponse.json({ error: 'タスクが見つかりません' }, { status: 404 })
}
// 認可: 自分のタスクのみ削除可能
if (existingTask.user_id !== userId) {
return NextResponse.json({ error: 'アクセス権限がありません' }, { status: 403 })
}
await prisma.task.delete({
where: { id: params.id },
})
return NextResponse.json({ message: '削除しました' })
} catch (error) {
console.error(`DELETE /api/tasks/${params.id} error:`, error)
return NextResponse.json({ error: 'サーバーエラー' }, { status: 500 })
}
}
components/tasks/task-list.tsx)'use client'
import { useState, useEffect } from 'react'
import { useAuth } from '@clerk/nextjs'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Select } from '@/components/ui/select'
import { TaskCard } from './task-card'
export function TaskList() {
const { getToken } = useAuth()
const [tasks, setTasks] = useState([])
const [loading, setLoading] = useState(true)
const [search, setSearch] = useState('')
const [status, setStatus] = useState('')
const [page, setPage] = useState(1)
useEffect(() => {
fetchTasks()
}, [search, status, page])
const fetchTasks = async () => {
setLoading(true)
try {
const token = await getToken()
const params = new URLSearchParams({
page: page.toString(),
...(search && { search }),
...(status && { status }),
})
const res = await fetch(`/api/tasks?${params}`, {
headers: { Authorization: `Bearer ${token}` },
})
const data = await res.json()
setTasks(data.data)
} catch (error) {
console.error('Failed to fetch tasks:', error)
} finally {
setLoading(false)
}
}
return (
<div>
<div className="mb-4 flex gap-4">
<Input
placeholder="検索..."
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
<Select value={status} onValueChange={setStatus}>
<option value="">すべて</option>
<option value="todo">未着手</option>
<option value="in_progress">進行中</option>
<option value="done">完了</option>
</Select>
</div>
{loading ? (
<p>読み込み中...</p>
) : (
<div className="grid gap-4">
{tasks.map((task) => (
<TaskCard key={task.id} task={task} onUpdate={fetchTasks} />
))}
</div>
)}
<div className="mt-4 flex justify-center gap-2">
<Button onClick={() => setPage(p => Math.max(1, p - 1))} disabled={page === 1}>
前へ
</Button>
<span>ページ {page}</span>
<Button onClick={() => setPage(p => p + 1)}>
次へ
</Button>
</div>
</div>
)
}
__tests__/api/tasks.test.ts)import { describe, it, expect, beforeEach } from 'vitest'
import { createMocks } from 'node-mocks-http'
import { GET, POST } from '@/app/api/tasks/route'
describe('/api/tasks', () => {
beforeEach(() => {
// モックのセットアップ
})
describe('GET', () => {
it('認証なしの場合、401を返す', async () => {
const { req } = createMocks({ method: 'GET' })
const res = await GET(req as any)
expect(res.status).toBe(401)
})
it('タスク一覧を取得できる', async () => {
// テストコード
})
it('ページネーションが動作する', async () => {
// テストコード
})
it('検索が動作する', async () => {
// テストコード
})
})
describe('POST', () => {
it('タスクを作成できる', async () => {
// テストコード
})
it('バリデーションエラーを返す', async () => {
// テストコード
})
})
})
Supabaseを使用している場合、以下のRLSポリシーを自動設定:
-- tasksテーブルのRLSを有効化
ALTER TABLE tasks ENABLE ROW LEVEL SECURITY;
-- SELECT: 自分のタスクのみ取得可能
CREATE POLICY "Users can view their own tasks"
ON tasks FOR SELECT
USING (auth.uid() = user_id);
-- INSERT: 自分のタスクのみ作成可能
CREATE POLICY "Users can create their own tasks"
ON tasks FOR INSERT
WITH CHECK (auth.uid() = user_id);
-- UPDATE: 自分のタスクのみ更新可能
CREATE POLICY "Users can update their own tasks"
ON tasks FOR UPDATE
USING (auth.uid() = user_id);
-- DELETE: 自分のタスクのみ削除可能
CREATE POLICY "Users can delete their own tasks"
ON tasks FOR DELETE
USING (auth.uid() = user_id);
✅ CRUD機能が完成しました!
📄 生成したファイル:
prisma/schema.prisma- データベーススキーマlib/validations/task.ts- バリデーションapp/api/tasks/route.ts- API(一覧、作成)app/api/tasks/[id]/route.ts- API(詳細、更新、削除)components/tasks/task-list.tsx- フロントエンド__tests__/api/tasks.test.ts- テストケースsupabase/migrations/{{timestamp}}_tasks_rls.sql- RLSポリシー次にやること:
npx prisma migrate dev --name add_tasksを実行- テストを実行:
npm test- 動作確認:
npm run dev💡 ヒント: 他のエンティティも追加したい場合は、
/crud {{エンティティ名}}を実行してください。
例: tasksは1つのprojectに属する
model Task {
// ...
project_id String
project Project @relation(fields: [project_id], references: [id], onDelete: Cascade)
}
model Project {
id String @id @default(uuid())
name String
tasks Task[]
}
例: tasksは複数のtagsを持つ
model Task {
// ...
tags TaskTag[]
}
model Tag {
id String @id @default(uuid())
name String @unique
tasks TaskTag[]
}
model TaskTag {
task_id String
tag_id String
task Task @relation(fields: [task_id], references: [id], onDelete: Cascade)
tag Tag @relation(fields: [tag_id], references: [id], onDelete: Cascade)
@@id([task_id, tag_id])
}
このコマンドで生成されたコードは、本番環境でそのまま使用できます。