第9部:実践プロジェクト Step 33 / 36

実装フェーズ1:コア機能

認証、プロジェクト、タスクのCRUDなど、コア機能をClaude Codeで実装します。AIエージェントに的確な指示を出しながら、段階的にアプリケーションを構築していきましょう。

Phase 1 で実装する機能

1 Docker環境構築
2 ユーザー認証(JWT)
3 プロジェクトCRUD
4 タスクCRUD
5 カンバンボードUI

ステップ1:Docker環境構築

Claude Codeへの指示

TaskFlowプロジェクトのDocker開発環境を構築してください。

【コンテナ構成】
- db: MySQL 8.0(ポート3306)
  - データベース名: taskflow
  - ルートパスワード: rootpass
  - ユーザー: taskflow / taskflow_pass
  - ボリュームでデータ永続化
- backend: FastAPI(ポート8000)
  - Python 3.11ベース
  - ホットリロード有効
  - dbコンテナに依存
- frontend: Next.js 14(ポート3000)
  - Node.js 20ベース
  - ホットリロード有効
- redis: Redis 7(ポート6379)
  - キャッシュおよびWebSocket用
  - ボリュームでデータ永続化

【要件】
- compose.yamlを使用(docker-compose.ymlではなく)
- 各サービスのDockerfileも作成
- .env.exampleにデフォルト値を記載
- ヘルスチェックを全コンテナに設定

Claude Codeが行うこと

  • compose.yaml - Docker Compose設定ファイル
  • backend/Dockerfile - FastAPI用Dockerfile
  • frontend/Dockerfile - Next.js用Dockerfile
  • .env.example - 環境変数テンプレート
  • backend/requirements.txt - Python依存パッケージ
  • frontend/package.json - Node.js依存パッケージ

完成コード:compose.yaml

# compose.yaml
services:
  db:
    image: mysql:8.0
    container_name: taskflow-db
    environment:
      MYSQL_ROOT_PASSWORD: ${DB_ROOT_PASSWORD:-rootpass}
      MYSQL_DATABASE: ${DB_NAME:-taskflow}
      MYSQL_USER: ${DB_USER:-taskflow}
      MYSQL_PASSWORD: ${DB_PASSWORD:-taskflow_pass}
    ports:
      - "3306:3306"
    volumes:
      - mysql_data:/var/lib/mysql
    healthcheck:
      test: ["CMD", "mysqladmin", "ping", "-h", "localhost"]
      interval: 10s
      timeout: 5s
      retries: 5

  redis:
    image: redis:7-alpine
    container_name: taskflow-redis
    ports:
      - "6379:6379"
    volumes:
      - redis_data:/data
    healthcheck:
      test: ["CMD", "redis-cli", "ping"]
      interval: 10s
      timeout: 5s
      retries: 5

  backend:
    build:
      context: ./backend
      dockerfile: Dockerfile
    container_name: taskflow-backend
    ports:
      - "8000:8000"
    volumes:
      - ./backend:/app
    environment:
      DATABASE_URL: mysql+asyncmy://${DB_USER:-taskflow}:${DB_PASSWORD:-taskflow_pass}@db:3306/${DB_NAME:-taskflow}
      REDIS_URL: redis://redis:6379/0
      JWT_SECRET: ${JWT_SECRET:-dev-secret-key}
    depends_on:
      db:
        condition: service_healthy
      redis:
        condition: service_healthy
    healthcheck:
      test: ["CMD", "curl", "-f", "http://localhost:8000/health"]
      interval: 10s
      timeout: 5s
      retries: 5

  frontend:
    build:
      context: ./frontend
      dockerfile: Dockerfile
    container_name: taskflow-frontend
    ports:
      - "3000:3000"
    volumes:
      - ./frontend:/app
      - /app/node_modules
    environment:
      NEXT_PUBLIC_API_URL: http://localhost:8000
    depends_on:
      backend:
        condition: service_healthy

volumes:
  mysql_data:
  redis_data:

☑ 動作確認

# コンテナを起動
docker compose up -d

# 全コンテナが起動しているか確認
docker compose ps

# 期待される出力:
# taskflow-db        running (healthy)
# taskflow-redis     running (healthy)
# taskflow-backend   running (healthy)
# taskflow-frontend  running

# バックエンドの疎通確認
curl http://localhost:8000/health

# フロントエンドの疎通確認
curl http://localhost:3000

よくあるエラー

  • ポート競合:Bind for 0.0.0.0:3306 failed: port is already allocated → ローカルのMySQLを停止するか、compose.yamlのポートを変更
  • DB接続失敗:Can't connect to MySQL serverdepends_onのhealthcheckが正しく設定されているか確認。docker compose logs dbでログを確認
  • ビルドエラー:failed to solve: dockerfile parse error → Dockerfileの構文を確認。Claude Codeに「Dockerfileのビルドエラーを修正して」と指示

ステップ2:認証機能の実装

Claude Codeへの指示

JWT認証機能を実装してください。

【バックエンド(FastAPI)】
- POST /auth/register - ユーザー登録
  - メール、パスワード、表示名を受け取る
  - パスワードはbcryptでハッシュ化
- POST /auth/login - ログイン
  - メール+パスワードでJWTトークンを発行
  - アクセストークン(15分)+リフレッシュトークン(7日)
- GET /auth/me - 現在のユーザー情報
  - Authorizationヘッダーからトークンを検証
- POST /auth/refresh - トークンリフレッシュ

【フロントエンド(Next.js)】
- /login ログインページ
- /register 新規登録ページ
- Zustandで認証状態を管理
- axiosインターセプターでトークン自動付与
- 未認証時は/loginにリダイレクト

【テーブル設計】
- usersテーブル: id, email, password_hash, display_name, created_at, updated_at

Claude Codeが行うこと

  • backend/app/models/user.py - Userモデル
  • backend/app/routers/auth.py - 認証エンドポイント
  • backend/app/core/security.py - JWT・パスワード処理
  • backend/app/core/deps.py - 認証依存関数
  • frontend/src/stores/authStore.ts - Zustand認証ストア
  • frontend/src/app/login/page.tsx - ログインページ
  • frontend/src/app/register/page.tsx - 登録ページ
  • frontend/src/lib/axios.ts - axiosインスタンス設定

完成コード:バックエンド認証エンドポイント

# backend/app/routers/auth.py
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.deps import get_db, get_current_user
from app.core.security import (
    hash_password, verify_password,
    create_access_token, create_refresh_token
)
from app.models.user import User
from app.schemas.auth import (
    UserRegister, UserLogin, TokenResponse, UserResponse
)

router = APIRouter(prefix="/auth", tags=["auth"])

@router.post("/register", response_model=UserResponse)
async def register(data: UserRegister, db: AsyncSession = Depends(get_db)):
    # メール重複チェック
    existing = await User.get_by_email(db, data.email)
    if existing:
        raise HTTPException(
            status_code=status.HTTP_400_BAD_REQUEST,
            detail="このメールアドレスは既に登録されています"
        )
    user = User(
        email=data.email,
        password_hash=hash_password(data.password),
        display_name=data.display_name,
    )
    db.add(user)
    await db.commit()
    await db.refresh(user)
    return user

@router.post("/login", response_model=TokenResponse)
async def login(data: UserLogin, db: AsyncSession = Depends(get_db)):
    user = await User.get_by_email(db, data.email)
    if not user or not verify_password(data.password, user.password_hash):
        raise HTTPException(
            status_code=status.HTTP_401_UNAUTHORIZED,
            detail="メールアドレスまたはパスワードが正しくありません"
        )
    return TokenResponse(
        access_token=create_access_token(user.id),
        refresh_token=create_refresh_token(user.id),
        token_type="bearer",
    )

@router.get("/me", response_model=UserResponse)
async def get_me(current_user: User = Depends(get_current_user)):
    return current_user

完成コード:フロントエンド認証ストア

// frontend/src/stores/authStore.ts
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
import axios from '@/lib/axios';

interface User {
  id: number;
  email: string;
  display_name: string;
}

interface AuthState {
  user: User | null;
  token: string | null;
  isLoading: boolean;
  login: (email: string, password: string) => Promise<void>;
  register: (email: string, password: string, displayName: string) => Promise<void>;
  logout: () => void;
  fetchUser: () => Promise<void>;
}

export const useAuthStore = create<AuthState>()(
  persist(
    (set, get) => ({
      user: null,
      token: null,
      isLoading: false,

      login: async (email, password) => {
        const res = await axios.post('/auth/login', { email, password });
        set({ token: res.data.access_token });
        await get().fetchUser();
      },

      register: async (email, password, displayName) => {
        await axios.post('/auth/register', {
          email, password, display_name: displayName,
        });
        await get().login(email, password);
      },

      logout: () => {
        set({ user: null, token: null });
      },

      fetchUser: async () => {
        set({ isLoading: true });
        try {
          const res = await axios.get('/auth/me');
          set({ user: res.data });
        } catch {
          set({ user: null, token: null });
        } finally {
          set({ isLoading: false });
        }
      },
    }),
    { name: 'auth-storage' }
  )
);

☑ 動作確認

# 1. Swagger UIで確認
# ブラウザで http://localhost:8000/docs を開く

# 2. ユーザー登録をテスト
curl -X POST http://localhost:8000/auth/register \
  -H "Content-Type: application/json" \
  -d '{"email":"test@example.com","password":"password123","display_name":"テストユーザー"}'

# 3. ログインをテスト
curl -X POST http://localhost:8000/auth/login \
  -H "Content-Type: application/json" \
  -d '{"email":"test@example.com","password":"password123"}'

# 4. トークンを使って /auth/me を確認
curl http://localhost:8000/auth/me \
  -H "Authorization: Bearer <取得したトークン>"

# 5. フロントエンド確認
# ブラウザで http://localhost:3000/register にアクセス
# ユーザー登録 → 自動ログイン → ダッシュボードへリダイレクト

よくあるエラー

  • 422 Validation Error:リクエストボディのフィールド名がスキーマと一致しているか確認。display_nameのアンダースコアに注意
  • 401 Unauthorized:トークンの有効期限切れ、またはAuthorization: Bearerヘッダーの形式を確認
  • CORS エラー:FastAPIのCORSMiddlewarehttp://localhost:3000が許可されているか確認

ステップ3:プロジェクトCRUD

Claude Codeへの指示

プロジェクト管理のCRUD機能を実装してください。

【エンドポイント】
- GET /projects - 自分が参加しているプロジェクト一覧
- POST /projects - プロジェクト新規作成(作成者が自動的にownerになる)
- GET /projects/{id} - プロジェクト詳細
- PUT /projects/{id} - プロジェクト更新(ownerのみ)
- DELETE /projects/{id} - プロジェクト削除(ownerのみ)
- POST /projects/{id}/members - メンバー追加(ownerのみ)

【テーブル設計】
- projects: id, name, description, owner_id, created_at, updated_at
- project_members: id, project_id, user_id, role(owner/admin/member), joined_at

【フロントエンド】
- /projects 一覧ページ(カード表示)
- /projects/new 新規作成フォーム
- /projects/{id} プロジェクト詳細ページ

認証済みユーザーのみアクセス可能にしてください。
既存のauth機能のパターンに合わせて実装してください。

Claude Codeが行うこと

  • backend/app/models/project.py - Project, ProjectMemberモデル
  • backend/app/routers/projects.py - プロジェクトエンドポイント
  • backend/app/schemas/project.py - リクエスト/レスポンススキーマ
  • frontend/src/app/projects/page.tsx - プロジェクト一覧
  • frontend/src/app/projects/new/page.tsx - 新規作成フォーム
  • frontend/src/app/projects/[id]/page.tsx - プロジェクト詳細

完成コード:プロジェクトモデル

# backend/app/models/project.py
from sqlalchemy import Column, Integer, String, Text, ForeignKey, DateTime, Enum
from sqlalchemy.orm import relationship
from datetime import datetime
import enum
from app.core.database import Base

class MemberRole(str, enum.Enum):
    owner = "owner"
    admin = "admin"
    member = "member"

class Project(Base):
    __tablename__ = "projects"

    id = Column(Integer, primary_key=True, index=True)
    name = Column(String(255), nullable=False)
    description = Column(Text, nullable=True)
    owner_id = Column(Integer, ForeignKey("users.id"), nullable=False)
    created_at = Column(DateTime, default=datetime.utcnow)
    updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)

    owner = relationship("User", back_populates="owned_projects")
    members = relationship("ProjectMember", back_populates="project", cascade="all, delete-orphan")
    tasks = relationship("Task", back_populates="project", cascade="all, delete-orphan")

class ProjectMember(Base):
    __tablename__ = "project_members"

    id = Column(Integer, primary_key=True, index=True)
    project_id = Column(Integer, ForeignKey("projects.id"), nullable=False)
    user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
    role = Column(Enum(MemberRole), default=MemberRole.member)
    joined_at = Column(DateTime, default=datetime.utcnow)

    project = relationship("Project", back_populates="members")
    user = relationship("User")

☑ 動作確認

# 1. プロジェクト作成
curl -X POST http://localhost:8000/projects \
  -H "Authorization: Bearer <トークン>" \
  -H "Content-Type: application/json" \
  -d '{"name":"マイプロジェクト","description":"テスト用プロジェクト"}'

# 2. プロジェクト一覧取得
curl http://localhost:8000/projects \
  -H "Authorization: Bearer <トークン>"

# 3. プロジェクト詳細取得
curl http://localhost:8000/projects/1 \
  -H "Authorization: Bearer <トークン>"

# 4. メンバー追加
curl -X POST http://localhost:8000/projects/1/members \
  -H "Authorization: Bearer <トークン>" \
  -H "Content-Type: application/json" \
  -d '{"user_id":2,"role":"member"}'

# 5. フロントエンドで確認
# http://localhost:3000/projects にアクセス
# プロジェクトカードが表示されることを確認

ステップ4:タスクCRUD

Claude Codeへの指示

タスク管理のCRUD機能を実装してください。

【エンドポイント】
- GET /projects/{id}/tasks - プロジェクト内のタスク一覧(ステータス別)
- POST /projects/{id}/tasks - タスク新規作成
- PUT /tasks/{id} - タスク更新
- DELETE /tasks/{id} - タスク削除
- PUT /tasks/{id}/status - ステータス変更(カンバン移動用)

【テーブル設計】
- tasks: id, title, description, status, priority, assignee_id, project_id, position, created_at, updated_at
- status: "todo" | "in_progress" | "done"
- priority: "low" | "medium" | "high"
- positionはカンバンボード内の並び順(整数値)

【フロントエンド】
- タスク作成モーダル
- タスク編集モーダル
- タスク削除確認ダイアログ

プロジェクトメンバーのみタスク操作可能にしてください。

Claude Codeが行うこと

  • backend/app/models/task.py - Taskモデル
  • backend/app/routers/tasks.py - タスクエンドポイント
  • backend/app/schemas/task.py - リクエスト/レスポンススキーマ
  • frontend/src/components/tasks/TaskCreateModal.tsx - タスク作成モーダル
  • frontend/src/components/tasks/TaskEditModal.tsx - タスク編集モーダル
  • frontend/src/stores/taskStore.ts - Zustandタスクストア

完成コード:タスクモデル

# backend/app/models/task.py
from sqlalchemy import Column, Integer, String, Text, ForeignKey, DateTime, Enum
from sqlalchemy.orm import relationship
from datetime import datetime
import enum
from app.core.database import Base

class TaskStatus(str, enum.Enum):
    todo = "todo"
    in_progress = "in_progress"
    done = "done"

class TaskPriority(str, enum.Enum):
    low = "low"
    medium = "medium"
    high = "high"

class Task(Base):
    __tablename__ = "tasks"

    id = Column(Integer, primary_key=True, index=True)
    title = Column(String(255), nullable=False)
    description = Column(Text, nullable=True)
    status = Column(Enum(TaskStatus), default=TaskStatus.todo)
    priority = Column(Enum(TaskPriority), default=TaskPriority.medium)
    assignee_id = Column(Integer, ForeignKey("users.id"), nullable=True)
    project_id = Column(Integer, ForeignKey("projects.id"), nullable=False)
    position = Column(Integer, default=0)
    created_at = Column(DateTime, default=datetime.utcnow)
    updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)

    project = relationship("Project", back_populates="tasks")
    assignee = relationship("User")

完成コード:ステータス変更エンドポイント

# backend/app/routers/tasks.py(抜粋)
@router.put("/{task_id}/status")
async def update_task_status(
    task_id: int,
    data: TaskStatusUpdate,
    db: AsyncSession = Depends(get_db),
    current_user: User = Depends(get_current_user),
):
    task = await db.get(Task, task_id)
    if not task:
        raise HTTPException(status_code=404, detail="タスクが見つかりません")

    # プロジェクトメンバーか確認
    await verify_project_member(db, task.project_id, current_user.id)

    task.status = data.status
    task.position = data.position
    task.updated_at = datetime.utcnow()
    await db.commit()
    await db.refresh(task)
    return task

☑ 動作確認

# 1. タスク作成
curl -X POST http://localhost:8000/projects/1/tasks \
  -H "Authorization: Bearer <トークン>" \
  -H "Content-Type: application/json" \
  -d '{"title":"初めてのタスク","description":"テスト","priority":"high"}'

# 2. タスク一覧取得
curl http://localhost:8000/projects/1/tasks \
  -H "Authorization: Bearer <トークン>"

# 3. ステータス変更(todo → in_progress)
curl -X PUT http://localhost:8000/tasks/1/status \
  -H "Authorization: Bearer <トークン>" \
  -H "Content-Type: application/json" \
  -d '{"status":"in_progress","position":0}'

# 4. タスク更新
curl -X PUT http://localhost:8000/tasks/1 \
  -H "Authorization: Bearer <トークン>" \
  -H "Content-Type: application/json" \
  -d '{"title":"更新されたタスク","priority":"medium"}'

# 5. ステータス別にタスクが取得できることを確認
# レスポンスでtodo/in_progress/doneのグループ分けを確認

よくあるエラー

  • 403 Forbidden:プロジェクトメンバーでないユーザーがタスクを操作しようとした場合。project_membersテーブルにレコードがあるか確認
  • position重複:カンバン移動時にpositionが重複する場合は、同一ステータス内のpositionを再計算するロジックを追加

ステップ5:カンバンボードUI

Claude Codeへの指示

カンバンボードUIを実装してください。

【コンポーネント構成】
- KanbanBoard - ボード全体(3カラム表示)
- KanbanColumn - 各ステータスのカラム(Todo / In Progress / Done)
- TaskCard - 個別タスクカード

【要件】
- TanStack Queryでタスクデータをフェッチ
- Zustandでローカルのタスク状態を管理
- カラム間のドラッグ&ドロップ(Phase 2で本格実装、今はボタンで移動)
- タスクカードにはタイトル、優先度バッジ、担当者アバターを表示
- 優先度に応じた色分け(high: 赤, medium: 黄, low: 緑)
- レスポンシブ対応(モバイルではカラムを縦並び)

【データフェッチ】
- useQueryでGET /projects/{id}/tasks を取得
- useMutationでステータス変更を即座に反映(楽観的更新)

プロジェクト詳細ページ /projects/{id} にカンバンボードを表示してください。

Claude Codeが行うこと

  • frontend/src/components/kanban/KanbanBoard.tsx - ボード全体
  • frontend/src/components/kanban/KanbanColumn.tsx - カラムコンポーネント
  • frontend/src/components/kanban/TaskCard.tsx - タスクカード
  • frontend/src/hooks/useTasks.ts - TanStack Queryフック
  • frontend/src/stores/taskStore.ts - Zustandストア更新

完成コード:カンバンボードコンポーネント

// frontend/src/components/kanban/KanbanBoard.tsx
'use client';

import { useTasks } from '@/hooks/useTasks';
import { KanbanColumn } from './KanbanColumn';

const COLUMNS = [
  { id: 'todo', title: 'Todo', color: 'bg-gray-100' },
  { id: 'in_progress', title: 'In Progress', color: 'bg-blue-50' },
  { id: 'done', title: 'Done', color: 'bg-green-50' },
] as const;

interface KanbanBoardProps {
  projectId: number;
}

export function KanbanBoard({ projectId }: KanbanBoardProps) {
  const { tasks, isLoading, updateTaskStatus } = useTasks(projectId);

  if (isLoading) {
    return <div className="text-center py-12">読み込み中...</div>;
  }

  const tasksByStatus = {
    todo: tasks.filter(t => t.status === 'todo'),
    in_progress: tasks.filter(t => t.status === 'in_progress'),
    done: tasks.filter(t => t.status === 'done'),
  };

  return (
    <div className="grid grid-cols-1 md:grid-cols-3 gap-6">
      {COLUMNS.map(column => (
        <KanbanColumn
          key={column.id}
          title={column.title}
          color={column.color}
          tasks={tasksByStatus[column.id]}
          onMoveTask={updateTaskStatus}
        />
      ))}
    </div>
  );
}

完成コード:TanStack Queryフック

// frontend/src/hooks/useTasks.ts
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import axios from '@/lib/axios';
import { Task, TaskStatus } from '@/types/task';

export function useTasks(projectId: number) {
  const queryClient = useQueryClient();

  const { data: tasks = [], isLoading } = useQuery<Task[]>({
    queryKey: ['tasks', projectId],
    queryFn: async () => {
      const res = await axios.get(`/projects/${projectId}/tasks`);
      return res.data;
    },
  });

  const { mutate: updateTaskStatus } = useMutation({
    mutationFn: async ({
      taskId, status, position,
    }: {
      taskId: number; status: TaskStatus; position: number;
    }) => {
      await axios.put(`/tasks/${taskId}/status`, { status, position });
    },
    // 楽観的更新
    onMutate: async ({ taskId, status, position }) => {
      await queryClient.cancelQueries({ queryKey: ['tasks', projectId] });
      const previous = queryClient.getQueryData<Task[]>(['tasks', projectId]);

      queryClient.setQueryData<Task[]>(['tasks', projectId], old =>
        old?.map(t =>
          t.id === taskId ? { ...t, status, position } : t
        )
      );
      return { previous };
    },
    onError: (_err, _vars, context) => {
      queryClient.setQueryData(['tasks', projectId], context?.previous);
    },
    onSettled: () => {
      queryClient.invalidateQueries({ queryKey: ['tasks', projectId] });
    },
  });

  return { tasks, isLoading, updateTaskStatus };
}

☑ 動作確認

# 1. ブラウザで http://localhost:3000/projects/1 にアクセス
# 2. カンバンボードが3カラムで表示されることを確認
# 3. タスクカードが正しいカラムに表示されていることを確認
# 4. 「新しいタスク」ボタンからタスク作成モーダルを開く
# 5. タスクを作成し、Todoカラムに追加されることを確認
# 6. タスクカードの移動ボタンでステータスを変更
#    例:Todoのタスクを「In Progress」に移動
# 7. 優先度バッジの色分けを確認(赤/黄/緑)
# 8. モバイル表示(ブラウザ幅を縮小)でカラムが縦並びになることを確認
#
# ※ ドラッグ&ドロップはPhase 2で実装します

Phase 1 実装のポイント

  • 段階的に進める - 1機能ずつ動作確認しながら次へ進む。エラーを積み残さない
  • エージェントモードを活用 - Claude CodeのPlan Modeで全体像を把握してからコード生成
  • こまめにコミット - 機能単位でGitコミットを作成。git commit -m "feat: 認証機能を追加"
  • テストも一緒に - 各エンドポイントの実装時にテストコードも依頼する
  • エラーはAIに投げる - エラーメッセージをそのままClaude Codeに貼り付けて修正を依頼
アーキテクチャ決定 次へ:実装フェーズ2