SPEC v1.0 2026-05-13

すごろく型ロードマップ機能
実装仕様書

Claude Haiku 4.5 の Tool Use を中核に、ユーザーゴールから「ヒアリング12問 → マイルストーン3〜6個 → タスク3〜6個」のすごろく型学習ロードマップを生成し、進捗管理する機能の完全実装仕様。本書通りに作れば独立した会員サイトでも動作する。

SOURCE: archive-site (AIWS) / IMPLEMENT TARGET: any membership site with course-based content
SECTION 01

機能要件 / What

1.1 ユーザー体験フロー(一直線)

ユーザーがゴール画面を開く → 既存ゴール一覧、または「新規作成」ボタン
「ゴール作成モーダル」でゴールタイトル+詳細+期限を入力
POST /api/roadmap/clarify を呼ぶ → AIが 12問のヒアリング質問を生成(4セクション×3問)
ユーザーが質問に回答(選択肢 or 自由記述)。スキップ可
POST /api/roadmap/goals を呼ぶ → AIが マイルストーン3〜6個・各タスク3〜6個 を生成、DB保存
すごろくビューでロードマップ表示。最初のマイルストーンが active、他は locked
ユーザーがタスクをチェック完了 → マイルストーン全タスク完了で次が active に解放
全マイルストーン完了 → ゴール completed
任意のタイミングで POST /api/roadmap/goals/[id]/regenerate で再生成可

1.2 機能一覧

ID機能必須説明
F-01ゴール作成必須title/description/targetDate を入力
F-02ヒアリング質問生成必須4セクション×3問=12問をAIで動的生成
F-03ロードマップ生成必須ヒアリング回答+利用可能レッスン+ユーザーコンテキストから生成
F-04すごろく表示必須マイルストーン縦並び+各タスクをセル表示
F-05進捗管理必須タスク isCompleted トグル、マイルストーン自動 active/completed 遷移
F-06視聴済みレッスン同期必須ゴール作成/再生成直後に、既視聴レッスンに対応するタスクを自動完了
F-07再生成必須同じゴールに対してロードマップを再生成(既存タスクは削除→新規作成)
F-08ゴール一覧/詳細必須進捗率込みでリスト表示
F-09ヒアリング結果のMemory保存推奨他チャットでも参照できるよう Memory テーブルに保存
F-10マイルストーン手動編集任意タイトル変更・タスク追加削除

1.3 UI要件

SECTION 02

技術要件 / How

2.1 推奨スタック(オリジナル準拠)

レイヤ採用技術必須度代替案
言語TypeScript必須JavaScript も可。型安全推奨
フレームワークNext.js 14+ (App Router)推奨Hono / Express / FastAPI 等 SSR/APIサーバならOK
DBPostgreSQL推奨MySQL / SQLite 可。トランザクション必須
ORMPrisma推奨Drizzle / TypeORM 等
AI SDK@anthropic-ai/sdk必須HTTP直叩きでも可。Tool Use対応
AIモデルclaude-haiku-4-5-20251001必須Sonnet系も可(コスト×10)
認証既存システムを流用必須Bearer Token認証推奨

2.2 環境変数

ANTHROPIC_API_KEY=sk-ant-...               # Anthropic APIキー(必須)
DATABASE_URL=postgresql://...              # DB接続文字列
NEXTAUTH_SECRET=...                        # 認証Secret(既存利用可)
# 任意: ロードマップ機能のフィーチャーフラグ
ROADMAP_FEATURE_ENABLED=true

2.3 外部依存・コスト見積

SECTION 03

データモデル

3.1 新規テーブル(必須3つ)

Prisma記法で記載。他ORMに移植可能。

// ==================
// ゴール(最上位)
// ==================
model Goal {
  id              String      @id @default(cuid())
  userId          String
  title           String      // 200字以内
  description     String?     @db.Text
  targetDate      DateTime?
  status          String      @default("active")  // active | completed | archived
  generatedBy     String      @default("ai")      // ai | manual
  generationModel String?                          // 例: "claude-haiku-4-5-20251001"
  createdAt       DateTime    @default(now())
  updatedAt       DateTime    @updatedAt
  completedAt     DateTime?
  user            User        @relation(fields: [userId], references: [id], onDelete: Cascade)
  milestones      Milestone[]

  @@index([userId])
  @@index([userId, status])
}

// ==================
// マイルストーン(中間)
// ==================
model Milestone {
  id          String        @id @default(cuid())
  goalId      String
  title       String
  description String?       @db.Text
  sortOrder   Int           @default(0)
  status      String        @default("locked")    // locked | active | completed
  completedAt DateTime?
  createdAt   DateTime      @default(now())
  updatedAt   DateTime      @updatedAt
  goal        Goal          @relation(fields: [goalId], references: [id], onDelete: Cascade)
  tasks       RoadmapTask[]

  @@index([goalId])
  @@index([goalId, sortOrder])
}

// ==================
// タスク(葉ノード)
// ==================
model RoadmapTask {
  id          String    @id @default(cuid())
  milestoneId String
  userId      String                              // denormalize: 完了連動・所有権チェック高速化
  type        String                              // lesson | task
  title       String
  description String?   @db.Text
  lessonId    String?                             // type=lesson の場合のみ
  sortOrder   Int       @default(0)
  isCompleted Boolean   @default(false)
  completedAt DateTime?
  source      String    @default("ai")            // ai | manual | chat
  createdAt   DateTime  @default(now())
  updatedAt   DateTime  @updatedAt
  milestone   Milestone @relation(fields: [milestoneId], references: [id], onDelete: Cascade)
  user        User      @relation(fields: [userId], references: [id], onDelete: Cascade)
  lesson      Lesson?   @relation(fields: [lessonId], references: [id], onDelete: SetNull)

  @@index([milestoneId])
  @@index([userId])
  @@index([userId, lessonId])
}

3.2 既存テーブル依存(実装側が用意すべき構造)

テーブル必要フィールド(最低限)用途
Userid / name? / cohort?ユーザー識別
Courseid / title / isPublishedコース管理
Moduleid / courseId / title / sortOrder / isPublished章管理
Lessonid / moduleId / title / description / summary / sortOrder / isPublishedレッスン本体(AIに渡す候補)
UserCourseAccessuserId / courseIdこのユーザーがどのコースにアクセス権あるか
LessonProgressuserId / lessonId / isCompleted視聴済み判定(タスク自動完了に使用)
Memory 推奨userId / type / content / confidence / source / sourceIdヒアリング回答の永続化(他チャットで参照)
UserTag + Tag 任意userId / tagName学習レベル絞り込み(beginner/intermediate/advanced等)
重要な設計判断: Userテーブルは既存システムに 後付け不可。クライアントの既存ユーザー基盤にFK連動させること。UserCourseAccess の代わりに「全レッスン公開」のシンプルモデルでも可(その場合 getAvailableLessons は全Publishedレッスン返却)。
SECTION 04

AI生成エンジン

4.1 モデルとパラメータ

const GENERATION_MODEL    = "claude-haiku-4-5-20251001";
const MAX_LESSONS_IN_PROMPT = 60;        // プロンプトに含めるレッスン上限

// Phase 1 (clarify)
max_tokens: 3072
tool_choice: { type: "tool", name: "ask_questions" }

// Phase 2 (generate)
max_tokens: 4096
tool_choice: { type: "tool", name: "create_roadmap" }

4.2 Tool Use 定義(構造化出力の核)

Anthropic Tool Use を 強制呼び出し でJSON出力を保証する。

Tool A: ask_questions(Phase 1)

{
  "name": "ask_questions",
  "description": "ゴール達成に必要な情報をヒアリングするための質問を、4セクション×3問の合計12問生成する。",
  "input_schema": {
    "type": "object",
    "properties": {
      "sections": {
        "type": "array",
        "description": "4つのセクション (current/goal/constraints/success)",
        "items": {
          "type": "object",
          "properties": {
            "key": {
              "type": "string",
              "enum": ["current", "goal", "constraints", "success"]
            },
            "title": { "type": "string" },
            "questions": {
              "type": "array",
              "items": {
                "type": "object",
                "properties": {
                  "question":    { "type": "string" },
                  "suggestions": { "type": "array", "items": { "type": "string" } }
                },
                "required": ["question", "suggestions"]
              }
            }
          },
          "required": ["key", "title", "questions"]
        }
      }
    },
    "required": ["sections"]
  }
}

Tool B: create_roadmap(Phase 2)

{
  "name": "create_roadmap",
  "description": "ユーザーのゴールに対して、すごろく型のロードマップを作成する。既存レッスンと、必要に応じてカスタムタスクを組み合わせる。",
  "input_schema": {
    "type": "object",
    "properties": {
      "milestones": {
        "type": "array",
        "description": "ゴール達成までのマイルストーン(3〜6個推奨、簡単→難しい順)",
        "items": {
          "type": "object",
          "properties": {
            "title":       { "type": "string" },
            "description": { "type": "string" },
            "tasks": {
              "type": "array",
              "description": "このマイルストーンで完了すべきタスク(3〜6個)",
              "items": {
                "type": "object",
                "properties": {
                  "type":        { "type": "string", "enum": ["lesson", "task"] },
                  "title":       { "type": "string" },
                  "description": { "type": "string" },
                  "lessonId":    { "type": "string", "description": "type=lesson の場合に必須" }
                },
                "required": ["type", "title"]
              }
            }
          },
          "required": ["title", "tasks"]
        }
      }
    },
    "required": ["milestones"]
  }
}

4.3 出力バリデーション(必須)

SECTION 05

プロンプト全文(コピー可)

5.1 Phase 1: ヒアリング質問生成プロンプト

ユーザーのゴールに対して、ロードマップを設計するための深い情報収集を行います。
4セクション×3問 = 計12問を、ユーザーの状況に応じて具体的に作成してください。

ゴール: ${goalTitle}
${goalDescription ? `詳細: ${goalDescription}` : ""}
${userContext ? `\n${userContext}` : ""}

セクション構成(必ずこの順で4つ):
1. current  / "現在地"     ... 経験レベル・現状の課題・関連スキル
2. goal     / "ゴール詳細" ... 具体的なアウトプット・対象ユーザー・規模感
3. constraints / "制約"    ... 時間・予算・障害物
4. success  / "成功定義"   ... 完了基準・中間マイルストーン・根本動機

【最重要ルール】
- ゴール文面に明示されていない職業・業種・対象領域・既存ツールを勝手に仮定しない
- 例: ゴールが「Webアプリを作る」だけなら「不動産業向けですか?」など特定業種に絞った質問はNG
- ユーザーが「何のために」「どのレベルで」「どんな成果物を」を答えやすい中立的な質問にする

その他のルール:
- 質問は短く具体的に(1問あたり40字以内)
- suggestionsは3〜4個、短いフレーズ(10〜20字程度)、業種を限定しない汎用的な選択肢にする
- 「自由記述」は提示しない(フロント側で必ず追加される)

ask_questions ツールで返答してください。

5.2 Phase 2: ロードマップ生成プロンプト

あなたは学習コーチです。ユーザーのゴールに対して、すごろく型のロードマップを設計してください。

ゴール: ${goalTitle}
${goalDescription ? `詳細: ${goalDescription}` : ""}
${targetDate ? `期限: ${targetDate.toISOString().slice(0, 10)}` : ""}
${userContext ? `\n${userContext}\n` : ""}
${clarifyText}                                  // ヒアリング結果QA一覧

利用可能なレッスン:
${lessonList}                                   // - lessonId | コース > モジュール > レッスン | 要約 形式

ルール:
- マイルストーンは3〜6個、各マイルストーンに3〜6タスク
- ゴールに直接関連するレッスンを優先選択。lessonIdは必ず上記リストのIDから選ぶ
- 関連するレッスンがない領域は type="task" のカスタムタスクを作成
- 簡単→難しいの順、依存関係を考慮
- 各タスクのtitleは「動詞で始まる短文」(例: "MCPの基本概念を理解する")
- ユーザーのスキルレベル・制約・成功定義に合わせてタスクの粒度・難易度を調整
- ヒアリング結果から得られた具体情報をマイルストーン名やタスク説明に反映

create_roadmap ツールで返答してください。

5.3 ユーザーコンテキスト構築ルール(暴走防止の要)

userContext 文字列に何を入れるかが AI出力品質の決定的要因。過去メモリ全件を入れると業種推測で暴走する事故例あり。

入れて良いもの
入れてはいけないもの

理由: ゴールが「Webアプリを作る」のとき、過去メモリから「不動産業」を拾うと AI が「不動産業向けWebアプリ」を勝手に前提化し、質問もロードマップも歪む。

最後に必ず以下の 釘刺し文 を付与:

※ 上記は学習レベル調整のための参考情報。今回のゴールに直接関係しない過去の文脈・職業・業種は勝手に推測しないこと。
SECTION 06

API契約

全エンドポイントは Authorization: Bearer ${token} 必須。認証は既存システムを流用すること。

6.1 GET /api/roadmap/goals

ユーザーのゴール一覧を返す

Response 200:
{
  "goals": [
    {
      "id": "ckxxx",
      "title": "AIで動画編集を内製化する",
      "description": "...",
      "targetDate": "2026-08-31T00:00:00.000Z",
      "status": "active",
      "progressPct": 35,
      "totalTasks": 20,
      "completedTasks": 7,
      "createdAt": "...",
      "updatedAt": "..."
    }
  ]
}

6.2 POST /api/roadmap/clarify

12問ヒアリング質問を生成

Request:
{
  "goalTitle": "AIで動画編集を内製化する",
  "goalDescription": "外注を全社内化したい"
}

Response 200:
{
  "sections": [
    {
      "key": "current",
      "title": "現在地",
      "questions": [
        {
          "id": "current-1",
          "question": "このゴール領域での経験レベルは?",
          "suggestions": ["全くの初心者", "基本は知っている", "実務で触れている", "得意分野"]
        },
        ... 計3問
      ]
    },
    ... 4セクション計12問
  ]
}

6.3 POST /api/roadmap/goals

ゴール作成+ロードマップ生成+DB保存(トランザクション)

Request:
{
  "title": "AIで動画編集を内製化する",
  "description": "...",
  "targetDate": "2026-08-31",
  "clarifications": [
    { "question": "経験レベルは?", "answer": "基本は知っている" },
    { "question": "週の学習時間は?", "answer": "5〜10時間" },
    ...
  ]
}

Response 201:
{
  "goal": {
    "id": "...",
    "title": "...",
    "milestones": [
      {
        "id": "...",
        "title": "土台を作る",
        "status": "active",
        "tasks": [
          { "id": "...", "type": "lesson", "title": "...", "lessonId": "...", "isCompleted": false },
          { "id": "...", "type": "task",   "title": "...", "isCompleted": false }
        ]
      },
      ...
    ]
  }
}

Response 400: バリデーションエラー(title欠落・長さ超過など)
Response 401: 認証失敗

6.4 POST /api/roadmap/goals/[id]/regenerate

既存ゴールに対してロードマップを再生成(既存milestone/task削除→新規作成)

Request:
{
  "clarifications": [ ... 任意で再ヒアリング結果 ... ]
}

Response 200: 6.3 と同じ構造

6.5 PATCH /api/roadmap/tasks/[id]

タスクの完了状態を更新

Request:
{
  "isCompleted": true
}

Response 200:
{
  "task": { "id": "...", "isCompleted": true, "completedAt": "..." },
  "milestoneStatus": "active" | "completed",     // マイルストーン再計算結果
  "goalStatus": "active" | "completed"            // ゴール再計算結果
}
SECTION 07

周辺ロジック(地味だが重要)

7.1 視聴済みレッスン → タスク自動同期

ゴール作成直後・再生成直後に呼ばれる。新規ロードマップで type=lesson のタスクのうち、ユーザーが既に視聴完了済みのレッスンを自動で isCompleted=true にする。

async function syncCompletedLessonsToTasks(userId: string, goalId: string) {
  // 1. 該当ゴールのlessonId付きタスクを取得
  const tasks = await prisma.roadmapTask.findMany({
    where: {
      userId,
      lessonId: { not: null },
      milestone: { goalId },
    },
    select: { id: true, lessonId: true },
  });
  if (tasks.length === 0) return;

  // 2. それらのlessonIdの視聴完了状態を確認
  const lessonIds = Array.from(new Set(tasks.map((t) => t.lessonId!)));
  const completed = await prisma.lessonProgress.findMany({
    where: { userId, lessonId: { in: lessonIds }, isCompleted: true },
    select: { lessonId: true },
  });
  const completedSet = new Set(completed.map((p) => p.lessonId));

  // 3. 該当タスクを一括完了
  const taskIds = tasks.filter((t) => completedSet.has(t.lessonId!)).map((t) => t.id);
  if (taskIds.length === 0) return;
  await prisma.roadmapTask.updateMany({
    where: { id: { in: taskIds } },
    data: { isCompleted: true, completedAt: new Date() },
  });

  // 4. マイルストーンステータス再計算
  await recalculateMilestoneStatuses(goalId);
}

7.2 マイルストーン・ゴール ステータス再計算

タスク完了/未完了が変わるたびに呼ぶ。ロジック:

7.3 ヒアリング回答の Memory 保存 推奨

ゴール生成完了後、ヒアリングQAを Memory テーブルに保存。他の機能(チャット等)でユーザー文脈として参照できる。

await prisma.memory.createMany({
  data: clarifications.map((c) => ({
    userId: auth.userId,
    type: "fact",
    content: `${c.question} → ${c.answer}`,
    confidence: 0.9,
    source: "roadmap_clarification",
    sourceId: goal.id,
  })),
});

7.4 利用可能レッスン取得(クライアント側で実装)

既存DBに合わせて以下のシグネチャの関数を用意する:

interface AvailableLesson {
  id: string;
  title: string;
  description: string | null;
  summary: string | null;
  courseTitle: string;
  moduleTitle: string;
}

async function getAvailableLessons(userId: string): Promise {
  // 1. このユーザーがアクセス権を持つコースを取得(または全コース)
  // 2. 各コースの公開済みモジュール → 公開済みレッスンを順に展開
  // 3. ROADMAP_EXCLUDE_TITLE_PATTERNS に該当するレッスンは除外
  // 4. 配列で返す
}

// 除外パターン例(システム的に意味ないレッスンを排除)
const ROADMAP_EXCLUDE_TITLE_PATTERNS = [
  "バイブコーディング完全攻略",
  // クライアント側で必要に応じて追加
];
SECTION 08

エラーハンドリング・フォールバック

8.1 AI生成失敗時のフォールバック

UIをブロックしない。AI失敗してもゴール自体は作成し、ユーザーに「手動でタスクを追加してください」と促す。

function fallbackResult(): GenerationResult {
  return {
    milestones: [
      {
        title: "スタート",
        description: "AI生成に失敗しました。手動でタスクを追加してください。",
        tasks: [],
      },
    ],
    model: "fallback",
  };
}

// goalsテーブル側で記録
generatedBy: result.model === "fallback" ? "manual" : "ai"
generationModel: result.model

8.2 ヒアリング質問生成失敗時のフォールバック

事前定義の12問(SECTION_FALLBACKS)を返す。

const SECTION_FALLBACKS: ClarifyingSection[] = [
  {
    key: "current",
    title: "現在地",
    questions: [
      { id: "current-1", question: "このゴール領域での経験レベルは?",
        suggestions: ["全くの初心者", "基本は知っている", "実務で触れている", "得意分野"] },
      { id: "current-2", question: "今、何に詰まっていますか?",
        suggestions: ["何から始めるか不明", "技術選定で迷っている", "具体的な実装方法", "特になし"] },
      { id: "current-3", question: "関連する既存スキル・経験は?",
        suggestions: ["プログラミング経験あり", "デザイン経験あり", "マーケ経験あり", "特になし"] }
    ]
  },
  // goal / constraints / success セクションも同様に3問ずつ
  // → 完全版はオリジナル lib/roadmap-generator.ts の SECTION_FALLBACKS 参照
];

8.3 エラーパターン総覧

パターン対処ユーザー影響
Anthropic API エラー(5xx, timeout)fallbackResult を返す、ゴール自体は作成「手動追加してね」表示
tool_use ブロックが返らない同上+console.warn でログ同上
max_tokens 制限到達部分結果を採用、warnログマイルストーン数が想定より少ない可能性
lessonId が DB に存在しないtype を task にダウングレード、lessonId=nullレッスンリンクなしのタスクとして表示
milestone 0個fallbackResult「手動追加してね」表示
全 milestone のタスク合計0fallbackResult同上
ユーザー認証失敗401返却ログイン画面遷移
title 200字超 / description 2000字超400返却クライアント側で文字数制限実装すること
SECTION 09

実装ステップ(推奨順序)

STEP 01 — 1〜2日
DBスキーマ追加

Goal / Milestone / RoadmapTask の3テーブルをマイグレーション。既存 User / Lesson / LessonProgress とFK連動。

STEP 02 — 1日
getAvailableLessons の実装

クライアント既存のレッスンDB構造に合わせて実装。除外パターンも反映。

STEP 03 — 0.5日
Anthropic SDK セットアップ

npm i @anthropic-ai/sdk、ANTHROPIC_API_KEY 環境変数、疎通テスト。

STEP 04 — 1日
buildRoadmapUserContext の実装

学習レベル+進捗のみを返す純関数。業種情報は絶対に含めない

STEP 05 — 1〜2日
generateClarifyingQuestions(Phase 1)

Tool定義+プロンプト+SECTION_FALLBACKS。単体テストで12問返ることを確認。

STEP 06 — 2〜3日
generateRoadmap(Phase 2)

Tool定義+プロンプト+fallbackResult+バリデーション。lessonIdダウングレード処理を含む。

STEP 07 — 1〜2日
API ルート実装(5本)

GET/POST goals, POST clarify, POST regenerate, PATCH tasks。各認証+バリデーション+トランザクション。

STEP 08 — 1日
周辺ロジック

syncCompletedLessonsToTasks / recalculateMilestoneStatuses / Memory保存。

STEP 09 — 3〜5日
フロントエンド実装

ゴール一覧 → 作成モーダル → ヒアリングフォーム → すごろくビュー → 完了演出。最初は素のCSSで動かして、後でスタイル磨く。

STEP 10 — 2〜3日
テスト&受け入れ

後述の受け入れ基準を全件パス。AI失敗パターンも意図的に再現してテスト。

総工数の目安: 12〜20営業日(1人エンジニア・既存システム前提)。フロント実装次第で増減。
SECTION 10

落とし穴・注意事項

落とし穴 1: ユーザーコンテキスト過剰注入

Memory全件をAIに渡すと、過去メモリの業種・職業を AI が拾って勝手に推測する。絶対に学習レベル+進捗だけに絞ること。詳細は 5.3 参照。

落とし穴 2: tool_choice を省略する

tool_choice: { type: "tool", name: "..." } を省略すると AI が自然言語で返す可能性があり、JSON パースが壊れる。必ず強制呼び出しに

落とし穴 3: lessonId バリデーションを省く

AI は時々 幻覚で存在しない lessonId を返すavailableLessons の ID セットで検証し、不一致なら task にダウングレード。

落とし穴 4: max_tokens 不足

Phase 2 で 4096 未満にすると、マイルストーン6個×タスク6個の構造化JSONが途中で切れる。最低 4096、できれば 8192 推奨。

落とし穴 5: レッスン数が60を超える

MAX_LESSONS_IN_PROMPT = 60 制限を入れているのは、プロンプト肥大化を防ぐため。本当に60件超を扱うなら、事前にゴールタイトルでフィルタリングするロジックを追加するか、レッスンをカテゴリ別に絞ること。

落とし穴 6: 並列リクエストでの DB トランザクション

同一ユーザーが連続してゴール作成APIを叩くと、ロック競合が起きる可能性。1ユーザーあたり同時1リクエストに制限するレート制限を推奨。

落とし穴 7: 再生成時の既存タスク削除

regenerate API で既存 milestone/task を onDelete: Cascade で消すが、ユーザーの進捗(完了済みタスク)も消える。lessonId 連動タスクは syncCompletedLessonsToTasks で復元されるが、type=task のカスタムタスクは復元不可。UIで警告必須。

落とし穴 8: 認証の使い回し

RoadmapTask には userId を denormalize で持たせている。API側で必ず「リクエストユーザー === タスクの userId」を検証すること。milestoneId 経由でgoal経由でユーザー確認するのは遅い&漏れる。

SECTION 11

受け入れ基準(チェックリスト)

以下を全件パスして実装完了とする。

AC-01: ゴール作成API(POST /goals)で title のみ送信して、Anthropic API 呼び出し成功 → マイルストーン3〜6個・各タスク3〜6個が DB 作成される
AC-02: clarify API で 12問(4セクション×3問)が返ってくる
AC-03: AI が生成した lessonId のうち、DBに存在しないものは type=task にダウングレードされる
AC-04: Anthropic API キーを無効化した状態でゴール作成 → fallbackResult が動作し、ゴール自体は作成される(マイルストーン1個・タスク0)
AC-05: ゴール作成直後、既に視聴完了済みのレッスンに対応するタスクが自動で isCompleted=true になる
AC-06: マイルストーン内の全タスクを完了すると、次の locked マイルストーンが active になる
AC-07: 全マイルストーンが completed になると、ゴール status=completed + completedAt が記録される
AC-08: regenerate API で既存ロードマップが置き換わる。lesson連動タスクの進捗は引き継がれる
AC-09: ヒアリング回答が Memory テーブルに保存される(type=fact, source=roadmap_clarification)
AC-10: 業種勝手仮定が起きないことの確認: ゴール「Webアプリを作る」だけを送り、過去Memory に「不動産業」がある状態で生成 → マイルストーンやタスクに「不動産」「物件」等が出てこない
AC-11: 別ユーザーのタスクを PATCH しようとすると 401/403 が返る(所有権チェック)
AC-12: 利用可能レッスン数 = 0 の状態でゴール作成 → type=task のみのロードマップが生成される
SECTION 12

進捗追跡&管理者ページ

動画視聴完了 → 管理者画面の「完了」表示の自動反映を支える、5段階パイプラインの完全仕様。 管理者は 読み取り専用、ユーザー側 progress POST が単一の書き込み口、というシンプルな片方向設計。

12.1 全体パイプライン

① 動画プレイヤーcomponents/video/R2VideoPlayer.tsx

setIntervalで再生位置監視。onProgress(watchedSeconds, percentComplete) コールバックで上位に通知するだけ。完了判定や送信は行わない(疎結合設計)。

② レッスン視聴ページapp/(protected)/courses/[slug]/lessons/[lessonId]/page.tsx

progressIntervalRef で定期的に POST /api/lessons/[id]/progress を呼ぶ。完了判定はこの層で行う(動画終端 or 視聴率閾値で isCompleted: true を送信)。

③ progress APIapp/api/lessons/[id]/progress/route.ts

updateLessonProgress() で LessonProgress テーブル更新。isCompleted === true の時だけ ロードマップ連鎖をトリガー。

④ ロードマップ自動完了(連鎖)completeTasksForLesson()

該当 lessonId に紐づく全 RoadmapTask を isCompleted=true にし、影響を受けたゴールで recalculateMilestoneStatuses() を呼ぶ。マイルストーンゲートが locked→activeactive→completed に自動遷移。

⑤ 管理者ページAPIapp/api/admin/progress/route.ts

users × lessons × LessonProgress を並列取得。書き込みは一切しない。フロントで users×lessons マトリクスとして描画。

12.2 動画プレイヤーの実装契約

プレイヤーは 進捗計算だけ。送信責務を持たない。

// components/video/R2VideoPlayer.tsx
interface R2VideoPlayerProps {
  videoUrl: string;
  initialTime?: number;
  onProgress?: (watchedSeconds: number, percentComplete: number) => void;
  onEnded?: () => void;
}

const progressIntervalRef = useRef<NodeJS.Timeout | null>(null);

useEffect(() => {
  // 5秒ごとに再生位置を上位に通知
  progressIntervalRef.current = setInterval(() => {
    if (!video) return;
    const percent = video.duration > 0
      ? (video.currentTime / video.duration) * 100
      : 0;
    onProgress?.(video.currentTime, percent);
  }, 5000);

  return () => {
    if (progressIntervalRef.current) clearInterval(progressIntervalRef.current);
  };
}, [video, onProgress]);

12.3 レッスン視聴ページの送信ロジック

// app/(protected)/courses/[slug]/lessons/[lessonId]/page.tsx

const saveProgress = async (
  watchedSeconds: number,
  percentComplete: number,
  isCompleted: boolean
) => {
  try {
    await fetch(`/api/lessons/${lessonId}/progress`, {
      method: "POST",
      headers: {
        "Content-Type": "application/json",
        Authorization: `Bearer ${token}`,
      },
      body: JSON.stringify({ watchedSeconds, percentComplete, isCompleted }),
    });
  } catch (error) {
    console.error("Failed to save progress:", error);
  }
};

// 1) 視聴復帰: 前回の watchedSeconds から再開
if (lesson.progress?.watchedSeconds > 0 && !lesson.progress.isCompleted) {
  player.setCurrentTime(lesson.progress.watchedSeconds);
}

// 2) 定期保存: 30秒ごと(途中進捗、isCompleted=false)
progressIntervalRef.current = setInterval(async () => {
  await saveProgress(currentTime, percentComplete, false);
}, 30_000);

// 3) 動画終端 or 視聴率90%到達 → isCompleted=true 送信
const handleVideoEnded = async () => {
  await saveProgress(duration, 100, true);
  // ローカル状態も即座に completed に
  setLesson((l) => l && l.progress
    ? { ...l, progress: { ...l.progress, isCompleted: true } }
    : l
  );
};
完了判定の閾値は実装の判断: オリジナルは「動画終端 or 視聴率90%」だが、80%・95% などサイト方針で調整可。サーバー側は受け取った boolean を信用する。サーバーで視聴率の正当性チェックはしない(パフォーマンス/シンプル設計優先)。

12.4 progress API(核心ロジック)

// app/api/lessons/[id]/progress/route.ts
import { updateLessonProgress } from "@/lib/courses";
import { validateToken } from "@/lib/auth";

export async function POST(
  request: NextRequest,
  { params }: { params: Promise<{ id: string }> }
) {
  // 1. 認証
  const payload = await validateToken(request.headers.get("authorization"));
  if (!payload) return NextResponse.json({ error: "認証が必要です" }, { status: 401 });

  // 2. 入力バリデーション
  const { id } = await params;
  const { watchedSeconds, percentComplete, isCompleted } = await request.json();

  if (
    (watchedSeconds !== undefined && (typeof watchedSeconds !== "number" || watchedSeconds < 0)) ||
    (percentComplete !== undefined && (typeof percentComplete !== "number" || percentComplete < 0 || percentComplete > 100)) ||
    (isCompleted !== undefined && typeof isCompleted !== "boolean")
  ) {
    return NextResponse.json({ error: "無効なパラメータです" }, { status: 400 });
  }

  // 3. DB 更新(upsertでinsert/update自動判別)
  const progress = await updateLessonProgress(id, payload.userId, {
    watchedSeconds, percentComplete, isCompleted,
  });

  // 4. ★ レッスン完了時のみ、ロードマップタスクを連鎖完了
  let completedTaskIds: string[] = [];
  if (isCompleted === true) {
    try {
      const { completeTasksForLesson } = await import("@/lib/roadmap");
      completedTaskIds = await completeTasksForLesson(payload.userId, id);
    } catch (err) {
      console.error("[Roadmap sync error]", err);
      // ★ 失敗してもレッスン進捗の保存は成功扱い(疎結合)
    }
  }

  return NextResponse.json({ ...progress, completedTaskIds });
}

12.5 updateLessonProgress(DB 書き込み)

// lib/courses.ts 想定実装
export async function updateLessonProgress(
  lessonId: string,
  userId: string,
  input: {
    watchedSeconds?: number;
    percentComplete?: number;
    isCompleted?: boolean;
  }
) {
  return prisma.lessonProgress.upsert({
    where: { userId_lessonId: { userId, lessonId } },
    create: {
      userId,
      lessonId,
      watchedSeconds:  input.watchedSeconds  ?? 0,
      percentComplete: input.percentComplete ?? 0,
      isCompleted:     input.isCompleted     ?? false,
      status: input.isCompleted ? "completed" :
              input.percentComplete && input.percentComplete > 0 ? "in_progress" : "not_started",
      lastAccessedAt:  new Date(),
      completedAt:     input.isCompleted ? new Date() : null,
    },
    update: {
      // 退行防止: より大きい値だけ採用
      watchedSeconds:  input.watchedSeconds  !== undefined ? { set: input.watchedSeconds  } : undefined,
      percentComplete: input.percentComplete !== undefined ? { set: input.percentComplete } : undefined,
      isCompleted:     input.isCompleted     !== undefined ? { set: input.isCompleted     } : undefined,
      status: input.isCompleted === true ? "completed" : undefined,
      lastAccessedAt:  new Date(),
      completedAt:     input.isCompleted === true ? new Date() : undefined,
    },
  });
}
upsert推奨理由: 初回視聴はinsert、2回目以降はupdate@@unique([userId, lessonId]) 制約があるので競合は起きない。

12.6 completeTasksForLesson(連鎖の核)

// lib/roadmap.ts
export async function completeTasksForLesson(
  userId: string,
  lessonId: string
): Promise<string[]> {
  // 1. このユーザーの該当 lessonId に紐づく未完了 RoadmapTask を取得
  const tasks = await prisma.roadmapTask.findMany({
    where: { userId, lessonId, isCompleted: false },
    select: { id: true, milestoneId: true, milestone: { select: { goalId: true } } },
  });
  if (tasks.length === 0) return [];

  const taskIds = tasks.map((t) => t.id);

  // 2. 全タスクを isCompleted=true に
  await prisma.roadmapTask.updateMany({
    where: { id: { in: taskIds } },
    data: { isCompleted: true, completedAt: new Date() },
  });

  // 3. 影響を受けたゴールで milestone ステータス再計算
  const affectedGoalIds = Array.from(new Set(tasks.map((t) => t.milestone.goalId)));
  for (const goalId of affectedGoalIds) {
    await recalculateMilestoneStatuses(goalId);
  }

  return taskIds;
}

// recalculateMilestoneStatuses のロジック概要:
//   1. ゴール配下のmilestoneを sortOrder 順に取得(tasks含む)
//   2. 各milestoneで全タスク isCompleted=true なら status=completed
//   3. 最初の未完了milestoneを active に、それより後を locked に
//   4. 全milestoneがcompletedなら goal.status=completed, completedAt=now()

12.7 管理者ページAPI(読み取り専用)

// app/api/admin/progress/route.ts
import { validateAdminRequest } from "@/lib/admin-auth";

export async function GET(request: NextRequest) {
  // 1. 管理者認証
  const authResult = await validateAdminRequest(request);
  if (!authResult.success) {
    return NextResponse.json({ error: authResult.error }, { status: 401 });
  }

  // 2. クエリパラメータ
  const { searchParams } = new URL(request.url);
  const search = searchParams.get("search") || "";
  const cohort = searchParams.get("cohort");

  const userWhere: Record<string, unknown> = {};
  if (search) {
    userWhere.OR = [
      { email: { contains: search, mode: "insensitive" } },
      { name:  { contains: search, mode: "insensitive" } },
    ];
  }
  if (cohort) userWhere.cohort = cohort === "null" ? null : parseInt(cohort, 10);

  // 3. 並列取得(users / lessons / progressData)
  const [users, lessons, progressData] = await Promise.all([
    prisma.user.findMany({
      where: { ...userWhere, courseAccess: { some: {} } },
      select: { id: true, email: true, name: true, cohort: true,
                lastActiveAt: true, lineFriendId: true },
      orderBy: [{ cohort: "asc" }, { createdAt: "desc" }],
    }),
    prisma.lesson.findMany({
      where: {
        isPublished: true,
        lessonType: "video",
        module: { isPublished: true, course: { isPublished: true } },
      },
      select: {
        id: true, title: true, sortOrder: true,
        module: { select: {
          id: true, title: true, sortOrder: true,
          course: { select: { slug: true, title: true } },
        }},
      },
      orderBy: [{ module: { sortOrder: "asc" } }, { sortOrder: "asc" }],
    }),
    prisma.lessonProgress.findMany({
      select: { userId: true, lessonId: true, percentComplete: true,
                isCompleted: true, watchedSeconds: true, lastAccessedAt: true },
    }),
  ]);

  // 4. 統計計算
  const stats = {
    totalUsers:     users.length,
    totalVideos:    lessons.length,
    completedCount: progressData.filter((p) => p.isCompleted).length,
    averageCompletion: progressData.length > 0
      ? progressData.reduce((s, p) => s + p.percentComplete, 0) / progressData.length
      : 0,
  };

  return NextResponse.json({ users, videos: lessons, progress: progressData, stats });
}

12.8 管理者画面のマトリクス描画

フロント側は users × lessons マトリクスを生成して表示。

// app/(admin)/progress/page.tsx 想定実装
const { users, videos, progress, stats } = data;

// 1. 高速ルックアップ用のMap化
const progressMap = new Map<string, Map<string, ProgressEntry>>();
for (const p of progress) {
  if (!progressMap.has(p.userId)) progressMap.set(p.userId, new Map());
  progressMap.get(p.userId)!.set(p.lessonId, p);
}

// 2. マトリクス描画
<table>
  <thead>
    <tr>
      <th>ユーザー</th>
      {videos.map((v) => <th key={v.id}>{v.title}</th>)}
    </tr>
  </thead>
  <tbody>
    {users.map((u) => (
      <tr key={u.id}>
        <td>{u.name} ({u.email})</td>
        {videos.map((v) => {
          const p = progressMap.get(u.id)?.get(v.id);
          return (
            <td key={v.id}>
              {p?.isCompleted    ? "✅ 100%" :
               p?.percentComplete ? `🔵 ${Math.round(p.percentComplete)}%` :
               "⬜ 0%"}
            </td>
          );
        })}
      </tr>
    ))}
  </tbody>
</table>

表示例:

ユーザーLesson 1Lesson 2Lesson 3Lesson 4
User A✅ 100%✅ 100%🔵 45%⬜ 0%
User B✅ 100%🔵 60%⬜ 0%⬜ 0%
User C⬜ 0%⬜ 0%⬜ 0%⬜ 0%

12.9 自動反映が成立する仕組み(一言で)

管理者側は「書き込み」を一切していない。 ユーザーが POST /api/lessons/[id]/progress で書き込んだ LessonProgress.isCompleted=true を、 管理者APIが読み出して表示するだけ。書く側=ユーザー / 読む側=管理者 の片方向設計のため、 手動更新ボタン等は不要で常にリアルタイム反映される。

12.10 認証の分離

関数用途判定基準
validateTokenユーザーAPI有効なBearer Token + 該当ユーザー
validateAdminRequest管理者API管理者ロール(user.role === "admin" 等)必須

同じBearer Tokenでも、管理者APIへのアクセスは validateAdminRequest でロールチェック。一般ユーザートークンでは /api/admin/* は 401 を返す。

12.11 受け入れ基準(追加)

AC-13: ユーザーが動画を視聴して isCompleted=true 送信 → 管理者ページの該当セルが「✅」表示に変わる(リロード後)
AC-14: isCompleted=true 送信時、該当 lessonId に紐づく RoadmapTask が全て自動完了している(複数ゴールでも全部)
AC-15: 一般ユーザートークンで GET /api/admin/progress を叩くと 401 を返す
AC-16: 管理者ページの search パラメータで「email部分一致」「name部分一致」両方ヒットする
AC-17: 管理者ページの cohort=N で N期生のみフィルタされる。cohort=null で未所属ユーザーのみ
AC-18: 退会した(courseAccess を全削除された)ユーザーは管理者画面に表示されない
AC-19: 動画途中で離脱(percentComplete=45)→ 次回視聴時 watchedSeconds=45秒位置から再開する
AC-20: completeTasksForLesson が例外を投げても、LessonProgress 自体の保存は成功している

12.12 落とし穴(追加)

落とし穴 9: 退行(後退)保護

ユーザーが完了済みレッスンを再生し直すと、percentComplete が 0 から書き直される可能性。updateLessonProgress「より大きい watchedSeconds / percentComplete だけ採用」のロジックを明示実装すること。isCompleted=truefalse に上書きするのも要注意。

落とし穴 10: 大規模時のFull Scan

prisma.lessonProgress.findMany() は全件取得。ユーザー数 × レッスン数が膨らむとレスポンス劣化。受講生1000名×レッスン300本 = 30万行で要対策(cohortフィルタ・ページネーション・集計テーブル化)。

落とし穴 11: setInterval リーク

レッスン視聴ページから離脱する際、useEffect のcleanup関数で必ず clearInterval を呼ぶ。漏れるとブラウザタブで複数のintervalが並行動作し、APIが連打される。

落とし穴 12: 進捗保存の race condition

定期保存(30秒間隔)と動画終端の isCompleted=true 送信がほぼ同時に走ると、後者が前者で上書きされうる。動画終端ハンドラで先に clearInterval してから isCompleted=true を送ること。