Next.jsとSupabaseでオンライ学習プラットフォーム（LMS）を構築する | エピソード7: リアルタイムコメント機能の統合

こんにちは！前回のエピソードでは、Stripeを使ってコース購入機能を実装し、ユーザーがコースを購入してコンテンツにアクセスできるようにしました。今回は、Supabase Realtimeを活用してリアルタイムコメント機能を実装します。`comments`テーブルを設計し、ユーザーがレッスンにコメントを投稿・閲覧できるUIを構築、Row-Level Security（RLS）でコメントを保護します。これで、インタラクティブな学習体験がさらに向上します！

## このエピソードのゴール

- Supabaseに`comments`テーブルを設計し、`lessons`と関連付ける。
- Supabase Realtimeでコメントをリアルタイムに同期。
- コメント投稿・表示用のUIコンポーネントを構築。
- RLSでコメントを保護。

## 必要なもの

- 前回のプロジェクト（`next-lms`）がセットアップ済み。
- Supabaseプロジェクト（`courses`, `lessons`, `purchases`テーブル設定済み）。
- `@supabase/auth-helpers-nextjs`（既存）。
- 基本的なTypeScript、React、Next.jsの知識。

---

## ステップ1: `comments`テーブルの設計

レッスンごとのコメントを保存するテーブルを作成します。

1. **Supabaseダッシュボードでテーブル作成**：
   - [Supabaseダッシュボード](https://supabase.com/)にログイン。
   - 「Table Editor」→「New Table」で`comments`テーブルを作成。
   - 以下のカラムを定義：

| カラム名        | タイプ       | 説明                     |
|-----------------|--------------|--------------------------|
| id              | uuid         | 主キー（自動生成）       |
| lesson_id       | uuid         | レッスンID（外部キー）   |
| user_id         | uuid         | コメント投稿者のID       |
| content         | text         | コメント内容             |
| created_at      | timestamptz  | 作成日時（自動生成）     |

   - 外部キー制約を追加：
     ```sql
     ALTER TABLE comments
     ADD CONSTRAINT fk_lesson
     FOREIGN KEY (lesson_id)
     REFERENCES lessons (id)
     ON DELETE CASCADE;

     ALTER TABLE comments
     ADD CONSTRAINT fk_user
     FOREIGN KEY (user_id)
     REFERENCES auth.users (id)
     ON DELETE CASCADE;
     ```

   - 「Enable Row Level Security (RLS)」を有効化し、ポリシーを設定：
     - **Select Policy**: 誰でも閲覧可能（`true`）。
     - **Insert Policy**: 認証済みユーザーのみ（`auth.uid() = user_id`）。
     - **Update/Delete Policy**: 自分のコメントのみ（`auth.uid() = user_id`）。

2. **テストデータの挿入**：
   SupabaseのSQL Editorでテストデータを追加：

```sql
INSERT INTO comments (lesson_id, user_id, content)
VALUES
  ('既存のレッスンID', 'あなたのユーザーID', 'このレッスンはとても分かりやすいです！'),
  ('既存のレッスンID', 'あなたのユーザーID', '次のレッスンが楽しみです！');
```

**注意**: `lesson_id`は`lessons`テーブル、`user_id`はSupabase AuthのユーザーIDを確認してください。

---

## ステップ2: Supabase Realtimeの設定

Supabase Realtimeでコメントの変更をリアルタイムに同期します。

1. **Supabaseクライアントの更新**：
   `src/lib/supabase.ts`にコメント取得関数を追加：

```ts
import { createClient } from '@supabase/supabase-js';
import { createServerComponentClient } from '@supabase/auth-helpers-nextjs';
import { cookies } from 'next/headers';

const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;

export const supabase = createClient(supabaseUrl, supabaseKey);
export const supabaseServer = () =>
  createServerComponentClient({ cookies }, { supabaseUrl, supabaseKey });

export interface Course {
  id: string;
  title: string;
  description: string;
  price: number;
  instructor_id: string;
  created_at: string;
  updated_at: string;
  image_url?: string;
}

export interface Lesson {
  id: string;
  course_id: string;
  title: string;
  description: string;
  video_path: string;
  created_at: string;
  updated_at: string;
}

export interface Comment {
  id: string;
  lesson_id: string;
  user_id: string;
  content: string;
  created_at: string;
}

export async function getCourses(page: number = 1, limit: number = 10) {
  const start = (page - 1) * limit;
  const end = start + limit - 1;

  const { data, error } = await supabase
    .from('courses')
    .select('*')
    .range(start, end)
    .order('created_at', { ascending: false });

  if (error) throw error;
  return data as Course[];
}

export async function getCoursesCount() {
  const { count, error } = await supabase
    .from('courses')
    .select('*', { count: 'exact', head: true });

  if (error) throw error;
  return count || 0;
}

export async function getCourseById(id: string) {
  const { data, error } = await supabase
    .from('courses')
    .select('*')
    .eq('id', id)
    .single();

  if (error) throw error;
  return data as Course;
}

export async function getAllCourseIds() {
  const { data, error } = await supabase
    .from('courses')
    .select('id');

  if (error) throw error;
  return data.map((item) => ({ id: item.id }));
}

export async function getLessonsByCourseId(courseId: string) {
  const { data, error } = await supabase
    .from('lessons')
    .select('*')
    .eq('course_id', courseId)
    .order('created_at', { ascending: true });

  if (error) throw error;
  return data as Lesson[];
}

export async function getSignedVideoUrl(videoPath: string) {
  const { data, error } = await supabase.storage
    .from('course-videos')
    .createSignedUrl(videoPath, 3600);

  if (error) throw error;
  return data.signedUrl;
}

export async function getCommentsByLessonId(lessonId: string) {
  const { data, error } = await supabase
    .from('comments')
    .select('*, users!inner(email)')
    .eq('lesson_id', lessonId)
    .order('created_at', { ascending: true });

  if (error) throw error;
  return data as (Comment & { users: { email: string } })[];
}
```

このコードは：
- `getCommentsByLessonId`: レッスンIDに基づくコメントを取得（ユーザー情報を結合）。
- Supabase Realtimeはクライアントサイドで設定（後述）。

---

## ステップ3: コメントコンポーネントの構築

リアルタイムコメント用のクライアントサイドコンポーネントを構築します。

1. **コメントコンポーネントの作成**：
   `src/components/CommentSection.tsx`を作成：

```tsx
'use client';

import { useState, useEffect } from 'react';
import { useForm } from 'react-hook-form';
import { supabase } from '@/lib/supabase';
import { Comment } from '@/lib/supabase';

type CommentForm = {
  content: string;
};

export default function CommentSection({ lessonId, userId }: { lessonId: string; userId?: string }) {
  const [comments, setComments] = useState<(Comment & { users: { email: string } })[]>([]);
  const { register, handleSubmit, reset, formState: { errors } } = useForm<CommentForm>();

  useEffect(() => {
    // 初期コメントの取得
    const fetchComments = async () => {
      const { data } = await supabase
        .from('comments')
        .select('*, users!inner(email)')
        .eq('lesson_id', lessonId)
        .order('created_at', { ascending: true });
      setComments(data || []);
    };
    fetchComments();

    // リアルタイムサブスクリプション
    const channel = supabase
      .channel(`comments:lesson:${lessonId}`)
      .on(
        'postgres_changes',
        {
          event: '*',
          schema: 'public',
          table: 'comments',
          filter: `lesson_id=eq.${lessonId}`,
        },
        (payload) => {
          if (payload.eventType === 'INSERT') {
            setComments((prev) => [...prev, payload.new as Comment & { users: { email: string } }]);
          }
        }
      )
      .subscribe();

    return () => {
      supabase.removeChannel(channel);
    };
  }, [lessonId]);

  const onSubmit = async (data: CommentForm) => {
    if (!userId) {
      alert('コメントするにはログインしてください');
      return;
    }

    const { error } = await supabase
      .from('comments')
      .insert({
        lesson_id: lessonId,
        user_id: userId,
        content: data.content,
      });

    if (error) {
      alert(error.message);
    } else {
      reset();
    }
  };

  return (
    <div className="mt-6">
      <h3 className="text-xl font-semibold mb-4">コメント</h3>
      {userId ? (
        <form onSubmit={handleSubmit(onSubmit)} className="mb-6">
          <textarea
            {...register('content', { required: 'コメントを入力してください' })}
            className="w-full border p-2 rounded"
            rows={4}
            placeholder="コメントを入力..."
          />
          {errors.content && <p className="text-red-500 text-sm">{errors.content.message}</p>}
          <button
            type="submit"
            className="mt-2 bg-primary text-white px-4 py-2 rounded hover:bg-opacity-90"
          >
            投稿
          </button>
        </form>
      ) : (
        <p className="text-gray-600 mb-4">
          コメントするには<a href="/login" className="text-primary hover:underline">ログイン</a>してください。
        </p>
      )}
      <div className="space-y-4">
        {comments.map((comment) => (
          <div key={comment.id} className="border-l-4 pl-4 py-2">
            <p className="text-sm text-gray-500">{comment.users.email} - {new Date(comment.created_at).toLocaleString()}</p>
            <p>{comment.content}</p>
          </div>
        ))}
      </div>
    </div>
  );
}
```

このコードは：
- Supabase Realtimeでコメントの追加を監視し、即座にUIを更新。
- React Hook Formでコメント投稿フォームを構築。
- 認証済みユーザーのみ投稿可能。

---

## ステップ4: CDPへのコメント統合

コース詳細ページ（CDP）にコメント機能を追加します。`src/app/courses/[id]/page.tsx`を更新：

```tsx
import Image from 'next/image';
import { notFound } from 'next/navigation';
import { getCourseById, getLessonsByCourseId, getSignedVideoUrl, Course, Lesson } from '@/lib/supabase';
import { Metadata } from 'next';
import ReactPlayer from 'react-player';
import { supabaseServer } from '@/lib/supabase';
import { cookies } from 'next/headers';
import CommentSection from '@/components/CommentSection';

export async function generateStaticParams() {
  const courses = await getAllCourseIds();
  return courses.map((course) => ({
    id: course.id,
  }));
}

export async function generateMetadata({ params }: { params: { id: string } }): Promise<Metadata> {
  const course = await getCourseById(params.id);
  if (!course) return {};

  return {
    title: `${course.title} | Next.js LMS`,
    description: course.description,
    openGraph: {
      title: course.title,
      description: course.description,
      images: [{ url: course.image_url || 'https://via.placeholder.com/600x400' }],
    },
    twitter: {
      card: 'summary_large_image',
      title: course.title,
      description: course.description,
      images: [course.image_url || 'https://via.placeholder.com/600x400'],
    },
  };
}

export default async function CourseDetailPage({ params, searchParams }: { params: { id: string }, searchParams: { success?: string, canceled?: string } }) {
  const course = await getCourseById(params.id);
  const lessons = await getLessonsByCourseId(params.id);
  const supabase = supabaseServer();
  const { data: { user } } = await supabase.auth.getUser();

  if (!course) {
    notFound();
  }

  const { data: purchase } = await supabase
    .from('purchases')
    .select('*')
    .eq('user_id', user?.id || '')
    .eq('course_id', course.id)
    .single();

  const isPurchased = purchase && purchase.status === 'completed';

  const lessonsWithSignedUrls = isPurchased
    ? await Promise.all(
        lessons.map(async (lesson) => ({
          ...lesson,
          signedUrl: await getSignedVideoUrl(lesson.video_path),
        }))
      )
    : [];

  if (searchParams.success) {
    await supabase
      .from('purchases')
      .update({ status: 'completed' })
      .eq('course_id', course.id)
      .eq('user_id', user?.id || '');
  }

  return (
    <main className="container mx-auto p-4">
      <div className="bg-white rounded-lg shadow-lg p-6">
        <Image
          src={course.image_url || 'https://via.placeholder.com/600x400'}
          alt={course.title}
          width={600}
          height={400}
          className="w-full h-64 object-cover rounded-lg mb-6"
          priority
        />
        <h1 className="text-3xl font-bold mb-4">{course.title}</h1>
        <p className="text-gray-600 mb-4">{course.description}</p>
        <p className="text-2xl font-semibold text-primary mb-6">{course.price.toLocaleString()} 円</p>
        {isPurchased ? (
          <p className="text-green-500 mb-6">このコースは購入済みです！</p>
        ) : (
          <a
            href={`/checkout?courseId=${course.id}`}
            className="bg-primary text-white px-6 py-3 rounded hover:bg-opacity-90 mb-6 inline-block"
          >
            このコースを購入
          </a>
        )}
        {searchParams.success && <p className="text-green-500 mb-6">購入が完了しました！</p>}
        {searchParams.canceled && <p className="text-red-500 mb-6">購入がキャンセルされました。</p>}
        <h2 className="text-2xl font-semibold mb-4">レッスン一覧</h2>
        {isPurchased ? (
          <div className="space-y-6">
            {lessonsWithSignedUrls.map((lesson) => (
              <div key={lesson.id} className="border rounded-lg p-4">
                <h3 className="text-xl font-semibold">{lesson.title}</h3>
                <p className="text-gray-600 mb-4">{lesson.description}</p>
                <ReactPlayer
                  url={lesson.signedUrl}
                  controls
                  width="100%"
                  height="auto"
                  className="rounded-lg"
                />
                <CommentSection lessonId={lesson.id} userId={user?.id} />
              </div>
            ))}
          </div>
        ) : (
          <p className="text-gray-600">コースを購入するとレッスンを視聴できます。</p>
        )}
      </div>
    </main>
  );
}
```

このコードは：
- 各レッスンに`<CommentSection>`を追加。
- 購入済みユーザーのみコメント機能を利用可能。

---

## ステップ5: 動作確認

1. Supabaseダッシュボードで以下を確認：
   - `comments`テーブルとRLSポリシーが正しい。
   - テストデータが`comments`テーブルに追加済み。
2. 開発サーバーを起動：

```bash
npm run dev
```

3. `http://localhost:3000/courses/[course-id]`にアクセスし、以下の点を確認：
   - 購入済みコースの場合、レッスン下にコメントセクションが表示される。
   - 認証済みユーザーがコメントを投稿でき、即座にUIに反映される（リアルタイム）。
   - 未認証ユーザーに「ログインしてください」メッセージが表示される。
   - コメントに投稿者のメールアドレスと投稿日時が表示される。
   - 別のブラウザで同じレッスンにアクセスし、コメントがリアルタイムに同期される。
4. Supabaseの`comments`テーブルで、投稿データ（`lesson_id`, `user_id`, `content`）が正しいことを確認。

エラーがあれば、SupabaseのRLS設定やリアルタイムサブスクリプションのログを確認してください。

---

## まとめと次のステップ

このエピソードでは、Supabase Realtimeを使ってリアルタイムコメント機能を実装しました。`comments`テーブルを設計し、コメント投稿・表示用のUIを構築、RLSでセキュリティを確保しました。これで、ユーザーはレッスンについてインタラクティブに交流できるようになりました！

次回のエピソードでは、LMSのパフォーマンスを最適化し、Core Web Vitalsのスコアを向上させます。Lighthouseでの分析、LCPやCLSの改善、React Server Componentsの活用を行いますので、引き続きお楽しみに！

---

この記事が役に立ったと思ったら、ぜひ「いいね」を押して、ストックしていただければ嬉しいです！次回のエピソードもお楽しみに！