Claude Haiku 4.5 の Tool Use を中核に、ユーザーゴールから「ヒアリング12問 → マイルストーン3〜6個 → タスク3〜6個」のすごろく型学習ロードマップを生成し、進捗管理する機能の完全実装仕様。本書通りに作れば独立した会員サイトでも動作する。
POST /api/roadmap/clarify を呼ぶ → AIが 12問のヒアリング質問を生成(4セクション×3問)POST /api/roadmap/goals を呼ぶ → AIが マイルストーン3〜6個・各タスク3〜6個 を生成、DB保存active、他は lockedactive に解放completedPOST /api/roadmap/goals/[id]/regenerate で再生成可| 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 | マイルストーン手動編集 | 任意 | タイトル変更・タスク追加削除 |
locked(鍵アイコン)/active(光る枠)/completed(チェック+クラッカー演出)| レイヤ | 採用技術 | 必須度 | 代替案 |
|---|---|---|---|
| 言語 | TypeScript | 必須 | JavaScript も可。型安全推奨 |
| フレームワーク | Next.js 14+ (App Router) | 推奨 | Hono / Express / FastAPI 等 SSR/APIサーバならOK |
| DB | PostgreSQL | 推奨 | MySQL / SQLite 可。トランザクション必須 |
| ORM | Prisma | 推奨 | Drizzle / TypeORM 等 |
| AI SDK | @anthropic-ai/sdk | 必須 | HTTP直叩きでも可。Tool Use対応 |
| AIモデル | claude-haiku-4-5-20251001 | 必須 | Sonnet系も可(コスト×10) |
| 認証 | 既存システムを流用 | 必須 | Bearer Token認証推奨 |
ANTHROPIC_API_KEY=sk-ant-... # Anthropic APIキー(必須) DATABASE_URL=postgresql://... # DB接続文字列 NEXTAUTH_SECRET=... # 認証Secret(既存利用可) # 任意: ロードマップ機能のフィーチャーフラグ ROADMAP_FEATURE_ENABLED=true
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])
}| テーブル | 必要フィールド(最低限) | 用途 |
|---|---|---|
User | id / name? / cohort? | ユーザー識別 |
Course | id / title / isPublished | コース管理 |
Module | id / courseId / title / sortOrder / isPublished | 章管理 |
Lesson | id / moduleId / title / description / summary / sortOrder / isPublished | レッスン本体(AIに渡す候補) |
UserCourseAccess | userId / courseId | このユーザーがどのコースにアクセス権あるか |
LessonProgress | userId / lessonId / isCompleted | 視聴済み判定(タスク自動完了に使用) |
Memory 推奨 | userId / type / content / confidence / source / sourceId | ヒアリング回答の永続化(他チャットで参照) |
UserTag + Tag 任意 | userId / tagName | 学習レベル絞り込み(beginner/intermediate/advanced等) |
UserCourseAccess の代わりに「全レッスン公開」のシンプルモデルでも可(その場合 getAvailableLessons は全Publishedレッスン返却)。
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" }Anthropic Tool Use を 強制呼び出し でJSON出力を保証する。
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"]
}
}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"]
}
}tool_use ブロックが返らない → フォールバック(後述)milestones が空 → フォールバックtitle は 200字でslice、description は 1000字でslicelessonId が availableLessons のIDセットに含まれない → typeを task にダウングレード、lessonId=nulltype が lesson でも task でもない → task にダウングレードユーザーのゴールに対して、ロードマップを設計するための深い情報収集を行います。
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 ツールで返答してください。あなたは学習コーチです。ユーザーのゴールに対して、すごろく型のロードマップを設計してください。
ゴール: ${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 ツールで返答してください。userContext 文字列に何を入れるかが AI出力品質の決定的要因。過去メモリ全件を入れると業種推測で暴走する事故例あり。
beginner / intermediate / advanced)理由: ゴールが「Webアプリを作る」のとき、過去メモリから「不動産業」を拾うと AI が「不動産業向けWebアプリ」を勝手に前提化し、質問もロードマップも歪む。
最後に必ず以下の 釘刺し文 を付与:
※ 上記は学習レベル調整のための参考情報。今回のゴールに直接関係しない過去の文脈・職業・業種は勝手に推測しないこと。
全エンドポイントは Authorization: Bearer ${token} 必須。認証は既存システムを流用すること。
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": "..."
}
]
}POST /api/roadmap/clarify12問ヒアリング質問を生成
Request:
{
"goalTitle": "AIで動画編集を内製化する",
"goalDescription": "外注を全社内化したい"
}
Response 200:
{
"sections": [
{
"key": "current",
"title": "現在地",
"questions": [
{
"id": "current-1",
"question": "このゴール領域での経験レベルは?",
"suggestions": ["全くの初心者", "基本は知っている", "実務で触れている", "得意分野"]
},
... 計3問
]
},
... 4セクション計12問
]
}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: 認証失敗POST /api/roadmap/goals/[id]/regenerate既存ゴールに対してロードマップを再生成(既存milestone/task削除→新規作成)
Request:
{
"clarifications": [ ... 任意で再ヒアリング結果 ... ]
}
Response 200: 6.3 と同じ構造PATCH /api/roadmap/tasks/[id]タスクの完了状態を更新
Request:
{
"isCompleted": true
}
Response 200:
{
"task": { "id": "...", "isCompleted": true, "completedAt": "..." },
"milestoneStatus": "active" | "completed", // マイルストーン再計算結果
"goalStatus": "active" | "completed" // ゴール再計算結果
}ゴール作成直後・再生成直後に呼ばれる。新規ロードマップで 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);
}タスク完了/未完了が変わるたびに呼ぶ。ロジック:
status=completedcompleted になったら、次の sortOrder の locked マイルストーンを active にcompleted → ゴール status=completed, completedAt 記録ゴール生成完了後、ヒアリング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,
})),
});既存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 = [
"バイブコーディング完全攻略",
// クライアント側で必要に応じて追加
]; UIをブロックしない。AI失敗してもゴール自体は作成し、ユーザーに「手動でタスクを追加してください」と促す。
function fallbackResult(): GenerationResult {
return {
milestones: [
{
title: "スタート",
description: "AI生成に失敗しました。手動でタスクを追加してください。",
tasks: [],
},
],
model: "fallback",
};
}
// goalsテーブル側で記録
generatedBy: result.model === "fallback" ? "manual" : "ai"
generationModel: result.model事前定義の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 参照
];| パターン | 対処 | ユーザー影響 |
|---|---|---|
| Anthropic API エラー(5xx, timeout) | fallbackResult を返す、ゴール自体は作成 | 「手動追加してね」表示 |
| tool_use ブロックが返らない | 同上+console.warn でログ | 同上 |
| max_tokens 制限到達 | 部分結果を採用、warnログ | マイルストーン数が想定より少ない可能性 |
| lessonId が DB に存在しない | type を task にダウングレード、lessonId=null | レッスンリンクなしのタスクとして表示 |
| milestone 0個 | fallbackResult | 「手動追加してね」表示 |
| 全 milestone のタスク合計0 | fallbackResult | 同上 |
| ユーザー認証失敗 | 401返却 | ログイン画面遷移 |
| title 200字超 / description 2000字超 | 400返却 | クライアント側で文字数制限実装すること |
Goal / Milestone / RoadmapTask の3テーブルをマイグレーション。既存 User / Lesson / LessonProgress とFK連動。
クライアント既存のレッスンDB構造に合わせて実装。除外パターンも反映。
npm i @anthropic-ai/sdk、ANTHROPIC_API_KEY 環境変数、疎通テスト。
学習レベル+進捗のみを返す純関数。業種情報は絶対に含めない。
Tool定義+プロンプト+SECTION_FALLBACKS。単体テストで12問返ることを確認。
Tool定義+プロンプト+fallbackResult+バリデーション。lessonIdダウングレード処理を含む。
GET/POST goals, POST clarify, POST regenerate, PATCH tasks。各認証+バリデーション+トランザクション。
syncCompletedLessonsToTasks / recalculateMilestoneStatuses / Memory保存。
ゴール一覧 → 作成モーダル → ヒアリングフォーム → すごろくビュー → 完了演出。最初は素のCSSで動かして、後でスタイル磨く。
後述の受け入れ基準を全件パス。AI失敗パターンも意図的に再現してテスト。
Memory全件をAIに渡すと、過去メモリの業種・職業を AI が拾って勝手に推測する。絶対に学習レベル+進捗だけに絞ること。詳細は 5.3 参照。
tool_choice: { type: "tool", name: "..." } を省略すると AI が自然言語で返す可能性があり、JSON パースが壊れる。必ず強制呼び出しに。
AI は時々 幻覚で存在しない lessonId を返す。availableLessons の ID セットで検証し、不一致なら task にダウングレード。
Phase 2 で 4096 未満にすると、マイルストーン6個×タスク6個の構造化JSONが途中で切れる。最低 4096、できれば 8192 推奨。
MAX_LESSONS_IN_PROMPT = 60 制限を入れているのは、プロンプト肥大化を防ぐため。本当に60件超を扱うなら、事前にゴールタイトルでフィルタリングするロジックを追加するか、レッスンをカテゴリ別に絞ること。
同一ユーザーが連続してゴール作成APIを叩くと、ロック競合が起きる可能性。1ユーザーあたり同時1リクエストに制限するレート制限を推奨。
regenerate API で既存 milestone/task を onDelete: Cascade で消すが、ユーザーの進捗(完了済みタスク)も消える。lessonId 連動タスクは syncCompletedLessonsToTasks で復元されるが、type=task のカスタムタスクは復元不可。UIで警告必須。
RoadmapTask には userId を denormalize で持たせている。API側で必ず「リクエストユーザー === タスクの userId」を検証すること。milestoneId 経由でgoal経由でユーザー確認するのは遅い&漏れる。
以下を全件パスして実装完了とする。
動画視聴完了 → 管理者画面の「完了」表示の自動反映を支える、5段階パイプラインの完全仕様。 管理者は 読み取り専用、ユーザー側 progress POST が単一の書き込み口、というシンプルな片方向設計。
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 を送信)。
app/api/lessons/[id]/progress/route.ts)
updateLessonProgress() で LessonProgress テーブル更新。isCompleted === true の時だけ ロードマップ連鎖をトリガー。
completeTasksForLesson())
該当 lessonId に紐づく全 RoadmapTask を isCompleted=true にし、影響を受けたゴールで recalculateMilestoneStatuses() を呼ぶ。マイルストーンゲートが locked→active や active→completed に自動遷移。
app/api/admin/progress/route.ts)
users × lessons × LessonProgress を並列取得。書き込みは一切しない。フロントで users×lessons マトリクスとして描画。
プレイヤーは 進捗計算だけ。送信責務を持たない。
// 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]);// 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
);
};// 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 });
}// 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,
},
});
}insert、2回目以降はupdate。@@unique([userId, lessonId]) 制約があるので競合は起きない。
// 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()// 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 });
}フロント側は 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 1 | Lesson 2 | Lesson 3 | Lesson 4 |
|---|---|---|---|---|
| User A | ✅ 100% | ✅ 100% | 🔵 45% | ⬜ 0% |
| User B | ✅ 100% | 🔵 60% | ⬜ 0% | ⬜ 0% |
| User C | ⬜ 0% | ⬜ 0% | ⬜ 0% | ⬜ 0% |
管理者側は「書き込み」を一切していない。
ユーザーが POST /api/lessons/[id]/progress で書き込んだ LessonProgress.isCompleted=true を、
管理者APIが読み出して表示するだけ。書く側=ユーザー / 読む側=管理者 の片方向設計のため、
手動更新ボタン等は不要で常にリアルタイム反映される。
| 関数 | 用途 | 判定基準 |
|---|---|---|
validateToken | ユーザーAPI | 有効なBearer Token + 該当ユーザー |
validateAdminRequest | 管理者API | 管理者ロール(user.role === "admin" 等)必須 |
同じBearer Tokenでも、管理者APIへのアクセスは validateAdminRequest でロールチェック。一般ユーザートークンでは /api/admin/* は 401 を返す。
GET /api/admin/progress を叩くと 401 を返すユーザーが完了済みレッスンを再生し直すと、percentComplete が 0 から書き直される可能性。updateLessonProgress で 「より大きい watchedSeconds / percentComplete だけ採用」のロジックを明示実装すること。isCompleted=true を false に上書きするのも要注意。
prisma.lessonProgress.findMany() は全件取得。ユーザー数 × レッスン数が膨らむとレスポンス劣化。受講生1000名×レッスン300本 = 30万行で要対策(cohortフィルタ・ページネーション・集計テーブル化)。
レッスン視聴ページから離脱する際、useEffect のcleanup関数で必ず clearInterval を呼ぶ。漏れるとブラウザタブで複数のintervalが並行動作し、APIが連打される。
定期保存(30秒間隔)と動画終端の isCompleted=true 送信がほぼ同時に走ると、後者が前者で上書きされうる。動画終端ハンドラで先に clearInterval してから isCompleted=true を送ること。