0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

実務未経験エンジニアがLaravel&ReactでWebアプリケーション開発に挑戦してみた!(バックエンド実装編⑩)~ブックマーク機能~

Posted at

実務未経験エンジニアがLaravel&ReactでWebアプリケーション開発に挑戦してみた!(その16)

0. 初めに

こんにちは!
このシリーズでは、実務未経験エンジニアの僕ががんばってWebアプリケーションを一つ完成させるまでの道のりをお届けしています!
初心者の方にもわかりやすく解説していますので、ぜひその1からご覧ください!

今回は、ブックマーク機能を作成したいと思います!
よろしくお願いいたします。

バックエンド編いつ終わるの...??

そろそろ皆さんの中にはこう思っている方がいらっしゃるのではないかと思います。

一体、機能をいくつ作るつもりなんや、コイツ...

ごもっともです!
最初にどのような機能を作るかをお示ししていませんでしたもんね。
先が見えない中、いつ終わるのかが見えなくてすみません💦

実を言うと、僕がこのシリーズを書き始めた5月の最初の時点では、お恥ずかしながらあまり「設計」というものを重視していませんでした。
そのため、(特にデータベースにおいて)たびたび修正を加えて、行き当たりばったりな印象を与えてしまったかもしれません。

そこで!

機能一覧表を作成しました!
本来、設計編の際に作るべきものでしたが、これによって、「どこまで機能が完成しているのか」または、「残りどれくらいの機能が残っているのか」が分かると思います。

Googleドライブにて共有しておりますので、よかったらご確認ください!

1. ブランチ運用

例によって、develop ブランチを最新化して、そこから新たに feature/14-bookmark というブランチを切って作業します。
今日の作業が完了したら、いつも通り、コミット・プッシュして、リモートの develop ブランチにマージしてください。

2. マイグレーション作成

テーブル設計を見直す

image.png
当初は、いいね(Likes)テーブルという名前にしていましたが、ブックマークに変更しました(変更が多くてすみません💦)。

というのも、「いいね」だとレビューと情報に被りが出てしまうような気がしたからです。
「レビューの数値」に加えて、「いいねの数」という両方とも研究室の評価に関する情報があることになります。
それよりは、単に「気になったから保存しておきたい」という意味合いが強い「ブックマーク」という機能名の方がしっくり来たため、テーブル名もそれに合わせて変更しました。

テーブル構造自体は変更はないので、そこまで気にすることはないです!

マイグレーションファイルを作成する

実行コマンド

/var/www
$ php artisan make:migration create_bookmarks_table
php\project-root\src\database\migrations\2025_07_21_142410_create_bookmarks_table.php
public function up(): void
    {
        Schema::create('bookmarks', function (Blueprint $table) {
            $table->id();
            $table->foreignId('user_id')->constrained()->onDelete('cascade');
            $table->foreignId('lab_id')->constrained()->onDelete('cascade');
            $table->unique(['user_id', 'lab_id']); // ユーザーと研究室の組み合わせは一意
            $table->timestamps();
        });
    }

マイグレーションを実行する

実行コマンド

/var/www
$ php artisan migrate

3. モデル作成

ブックマークモデルを作成する

実行コマンド

/var/www
$ php artisan make:model Bookmark
\project-root\src\app\Models\Bookmark.php
<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;

class Bookmark extends Model
{
    use HasFactory;

    protected $fillable = [
        'user_id',
        'lab_id',
    ];

    // リレーション定義
    public function user()
    {
        return $this->belongsTo(User::class);
    }

    public function lab()
    {
        return $this->belongsTo(Lab::class);
    }
}

ユーザーモデルと研究室モデルにリレーションを追加する

\project-root\src\app\Models\User.php
    // 追加: ブックマークとのリレーション(一対多)
    public function bookmarks()
    {
        return $this->hasMany(Bookmark::class);
    }
\project-root\src\app\Models\Lab.php
    // 追加: ブックマークとのリレーション(一対多)
    public function bookmarks()
    {
        return $this->hasMany(Bookmark::class);
    }

4. コントローラー作成

ブックマークコントローラーを作成する

実行コマンド

/var/www
$ php artisan make:controller BookmarkController
\project-root\src\app\Http\Controllers\BookmarkController.php
<?php

namespace App\Http\Controllers;

use App\Models\Bookmark;
use App\Models\Lab;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;

class BookmarkController extends Controller
{
    use AuthorizesRequests;

    public function store(Request $request)
    {
        // ポリシーで認可をチェック
        $this->authorize('create', Bookmark::class);

        // バリデーション
        $request->validate([
            'lab_id' => 'required|exists:labs,id',
        ]);

        // 既にブックマークされているか確認
        $existingBookmark = Bookmark::where('user_id', Auth::id())
            ->where('lab_id', $request->input('lab_id'))
            ->exists();

        if ($existingBookmark) {
            return redirect()->route('lab.show', $request->input('lab_id'))
                ->with('error', 'この研究室は既にブックマークされています。');
        }

        // ブックマークの作成
        $bookmark = new Bookmark();
        $bookmark->user_id = Auth::id();
        $bookmark->lab_id = $request->input('lab_id');
        $bookmark->save();

        return redirect()->route('labs.show', $bookmark->lab_id)->with('success', 'ブックマークが保存されました。');
    }

    public function destroy(Bookmark $bookmark)
    {
        // ポリシーで認可をチェック
        $this->authorize('delete', $bookmark);

        // 削除前にIDを保存
        $lab_id = $bookmark->lab_id;

        // ブックマークの削除
        $bookmark->delete();

        return redirect()->route('labs.show', $lab_id)->with('success', 'ブックマークが削除されました。');
    }
}

研究室コントローラーの Show() メソッドを修正する

研究室詳細ページにブックマークを表示したいので、LabController.phpshow() メソッドを以下のように修正します。

\project-root\src\app\Http\Controllers\LabController.php
    public function show(Lab $lab)
    {
        // 大学・学部、レビューのデータも一緒に渡す
        // universityはfacultyを経由して取得
        $lab->load(['faculty.university', 'reviews']);

        // コメントデータを取得(投稿者情報も含む)
        $comments = $lab->comments()->with('user')->latest()->get();

        // 平均値を計算するために、評価項目のカラム名を定義
        $ratingColumns = [
            'mentorship_style',
            'lab_atmosphere',
            'achievement_activity',
            'constraint_level',
            'facility_quality',
            'work_style',
            'student_balance',
        ];

        // 1. 各評価項目のユーザー間の平均値 (Average per Item) を計算
        $averagePerItem = collect($ratingColumns)->mapWithKeys(function ($column) use ($lab) {
            // 各評価項目の平均を計算(全レビューを対象)
            return [$column => $lab->reviews->avg($column)];
        });

        // 2. 新しい「総合評価」:各項目の平均値のさらに平均を計算
        // $averagePerItem の値(平均点)をコレクションとして取り出し、その平均を求める
        $overallAverage = $averagePerItem->avg();

        // 3. 現在のユーザーのレビューを取得
        $userReview = null;
        $userOverallAverage = null;
        
        if (Auth::check()) {
            $userReview = $lab->reviews->where('user_id', Auth::id())->first();
            
            // ユーザーのレビューが存在する場合、個別の総合評価を計算
            if ($userReview) {
                $userRatings = collect($ratingColumns)->map(function ($column) use ($userReview) {
                    return $userReview->$column;
                })->filter(function ($value) {
                    return $value !== null;
                });
                
                $userOverallAverage = $userRatings->avg();
            }
        }

        // 追加: ユーザーのブックマーク状態を取得
        $userBookmark = $lab->bookmarks()->where('user_id', Auth::id())->first();
        $bookmarkCount = $lab->bookmarks()->count();

        // 修正:
        // 研究室のデータに加えて、求めたレビューの平均値とユーザーのレビュー、コメント、ブックマーク、認証情報も一緒に渡す
        return Inertia::render('Lab/Show', [
            'lab' => $lab,
            'overallAverage' => $overallAverage,
            'averagePerItem' => $averagePerItem,
            'userReview' => $userReview,
            'userOverallAverage' => $userOverallAverage,
            'userBookmark' => $userBookmark, // 追加
            'bookmarkCount' => $bookmarkCount, // 追加
            'ratingData' => [
                'columns' => $ratingColumns,
            ],
            'comments' => $comments,
            'auth' => [
                'user' => Auth::user(),
            ],
        ]);
    }

ポリシーを追加する

\project-root\src\app\Policies\BookmarkPolicy.php
<?php

namespace App\Policies;

use App\Models\Bookmark;
use App\Models\User;

class BookmarkPolicy
{
    // ユーザーがブックマークを作成できるかどうかを判定
    public function create(User $user)
    {
        // ブックマークを作成できるのはログインユーザーのみ
        return $user->exists;
    }

    // ユーザーがブックマークを削除できるかどうかを判定
    public function delete(User $user, Bookmark $bookmark)
    {
        // 自分が作成したブックマークのみ削除可能
        return $user->id === $bookmark->user_id;
    }
}

\project-root\src\app\Providers\AppServiceProvider.php
<?php

namespace App\Providers;

use App\Models\Bookmark; // 追加
use App\Models\Comment;
use App\Models\Faculty;
use App\Models\Lab;
use App\Models\Review;
use App\Models\University;
use App\Policies\BookmarkPolicy; // 追加
use App\Policies\CommentPolicy;
use App\Policies\FacultyPolicy;
use App\Policies\LabPolicy;
use App\Policies\ReviewPolicy;
use App\Policies\UniversityPolicy;
use Illuminate\Support\Facades\Gate;
use Illuminate\Support\Facades\Vite;
use Illuminate\Support\ServiceProvider;

class AppServiceProvider extends ServiceProvider
{
    /**
     * Register any application services.
     */
    public function register(): void
    {
        //
    }

    /**
     * Bootstrap any application services.
     */
    public function boot(): void
    {
        Vite::prefetch(concurrency: 3);

        // ポリシーを登録
        Gate::policy(Review::class, ReviewPolicy::class);
        Gate::policy(University::class, UniversityPolicy::class);
        Gate::policy(Faculty::class, FacultyPolicy::class);
        Gate::policy(Lab::class, LabPolicy::class);
        Gate::policy(Comment::class, CommentPolicy::class);
        Gate::policy(Bookmark::class, BookmarkPolicy::class); // 追加
    }
}

5. ルーティング追加

auth ミドルウェアの中に追加です。

\project-root\src\routes\web.php
    // ブックマーク関連
    Route::post('/bookmarks', [BookmarkController::class, 'store'])->name('bookmark.store'); // 追加
    Route::delete('/bookmarks/{bookmark}', [BookmarkController::class, 'destroy'])->name('bookmark.destroy'); // 追加

6. Reactコンポーネント修正

Lab/Show.jsx にブックマークを表示するように修正を加えます。

\wsl.localhost\Ubuntu\home\kanaraki\app-lab-review\project-root\src\resources\js\Pages\Lab\Show.jsx
import React from 'react';
import { Head, Link, router } from '@inertiajs/react';

// propsとして新しく追加されたプロパティも受け取る
export default function Show({ 
    lab, 
    overallAverage, 
    averagePerItem, 
    userReview, 
    userOverallAverage, 
    ratingData,
    comments,
    auth,
    userBookmark,
    bookmarkCount
}) {
    const reviewCount = lab.reviews ? lab.reviews.length : 0;

    // ratingColumnsが空の場合、フォールバック用の配列を使用
    const fallbackRatingColumns = [
        'mentorship_style',
        'lab_atmosphere',
        'achievement_activity',
        'constraint_level',
        'facility_quality',
        'work_style',
        'student_balance',
    ];
    
    const ratingColumns = ratingData?.columns || fallbackRatingColumns;
    const actualRatingColumns = ratingColumns.length > 0 ? ratingColumns : fallbackRatingColumns;

    const itemLabels = {
        mentorship_style: '指導スタイル',
        lab_atmosphere: '雰囲気・文化',
        achievement_activity: '成果・活動',
        constraint_level: '拘束度',
        facility_quality: '設備',
        work_style: '働き方',
        student_balance: '人数バランス',
    };

    const formatAverage = (value) => {
        return value !== null && value !== undefined ? value.toFixed(2) : 'データなし';
    };

    const handleDeleteReview = (reviewId) => {
        if (confirm('本当に削除してもよろしいですか?')) {
            router.delete(route('review.destroy', { review: reviewId }), {
                onSuccess: () => {
                    // 成功時の処理
                    alert('レビューが削除されました。');
                },
                onError: (error) => {
                    // エラー時の処理
                    alert('レビューの削除に失敗しました。');
                }
            });
        }
    };

    const handleCreateReview = () => {
        router.get(route('review.create', { lab: lab.id }));
    };

    const handleEditReview = () => {
        router.get(route('review.edit', { review: userReview.id }));
    };

    const handleCreateComment = () => {
        router.get(route('comment.create', { lab: lab.id }));
    };

    const handleEditComment = (commentId) => {
        router.get(route('comment.edit', { comment: commentId }));
    };

    const handleDeleteComment = (commentId) => {
        if (confirm('本当に削除してもよろしいですか?')) {
            router.delete(route('comment.destroy', { comment: commentId }), {
                onSuccess: () => {
                    // 成功時の処理
                    alert('コメントが削除されました。');
                },
                onError: (error) => {
                    // エラー時の処理
                    alert('コメントの削除に失敗しました。');
                }
            });
        }
    };

    // ブックマーク追加
    const handleAddBookmark = () => {
        router.post(route('bookmark.store'), {
            lab_id: lab.id
        }, {
            onSuccess: () => {
                alert('ブックマークに追加しました。');
            },
            onError: (error) => {
                alert('ブックマークの追加に失敗しました。');
            }
        });
    };

    // ブックマーク削除
    const handleRemoveBookmark = () => {
        if (confirm('ブックマークを削除してもよろしいですか?')) {
            router.delete(route('bookmark.destroy', { bookmark: userBookmark.id }), {
                onSuccess: () => {
                    alert('ブックマークを削除しました。');
                },
                onError: (error) => {
                    alert('ブックマークの削除に失敗しました。');
                }
            });
        }
    };

    return (
        <div>
            <Head title={`${lab.name}の詳細`} />
            <h1>{lab.name} の詳細ページ</h1>
            
            {/* 研究室一覧に戻るボタン */}
            <div>
                <Link href={route('labs.index', lab.faculty_id)}>
                    <button>研究室一覧に戻る</button>
                </Link>
            </div>
            
            {/* 研究室編集ボタン */}
            <div>
                <Link href={route('lab.edit', lab.id)}>
                    <button>研究室を編集</button>
                </Link>
            </div>
            
            {/* 編集履歴ボタン */}
            <div>
                <Link href={route('lab.history', lab.id)}>
                    <button>編集履歴を見る</button>
                </Link>
            </div>
            
            {/* ブックマークボタン */}
            {auth && auth.user && (
                <div>
                    {userBookmark ? (
                        <button onClick={handleRemoveBookmark}>
                            ブックマークを削除
                        </button>
                    ) : (
                        <button onClick={handleAddBookmark}>
                            ブックマークに追加
                        </button>
                    )}
                </div>
            )}
            
            {/* ブックマーク数表示 */}
            {bookmarkCount !== undefined && (
                <p>ブックマーク数: {bookmarkCount}</p>
            )}
            
            {/* コメント投稿ボタン */}
            <div>
                <button onClick={handleCreateComment}>
                    コメントを投稿する
                </button>
            </div>
            
            <p>大学: {lab.faculty?.university?.name}</p>
            <p>学部: {lab.faculty?.name}</p>
            <p>研究室の説明: {lab.description}</p>
            <p>研究室のURL: <a href={lab.url} target="_blank" rel="noopener noreferrer">{lab.url}</a></p>
            <p>教授のURL: <a href={lab.professor_url} target="_blank" rel="noopener noreferrer">{lab.professor_url}</a></p>
            <p>男女比(男): {lab.gender_ratio_male}</p>
            <p>男女比(女): {lab.gender_ratio_female}</p>

            <hr />

            <h2>レビュー</h2>
            <p>レビュー数: {reviewCount}</p>
            
            {/* 全体の平均評価を表示 */}
            <h3>全体の評価(平均)</h3>
            <p><strong>総合評価: </strong>{formatAverage(overallAverage)}</p>

            <h4>各評価項目の平均:</h4>
            {averagePerItem && Object.keys(averagePerItem).length > 0 ? (
                <ul>
                    {Object.entries(averagePerItem).map(([itemKey, averageValue]) => (
                        <li key={itemKey}>
                            <p>
                                <strong>{itemLabels[itemKey] || itemKey}:</strong>
                                {formatAverage(averageValue)}
                            </p>
                        </li>
                    ))}
                </ul>
            ) : (
                <p>まだ評価データがありません。</p>
            )}

            {/* ユーザーのレビューが存在する場合に表示 */}
            {userReview ? (
                <div>
                    <h3>あなたの投稿したレビュー</h3>
                    <p><strong>総合評価: </strong>{formatAverage(userOverallAverage)}</p>
                    
                    <h4>各評価項目:</h4>
                    <ul>
                        {actualRatingColumns && actualRatingColumns.length > 0 ? (
                            actualRatingColumns.map((column) => {
                                const value = userReview[column];
                                return (
                                    <li key={column}>
                                        <p>
                                            <strong>{itemLabels[column] || column}:</strong>
                                            {value !== null && value !== undefined 
                                                ? (typeof value === 'number' ? value.toFixed(2) : value)
                                                : '未評価'}
                                        </p>
                                    </li>
                                )
                            })
                        ) : (
                            <li>評価項目データがありません</li>
                        )}
                    </ul>
                    
                    <div>
                        <button onClick={() => handleDeleteReview(userReview.id)}>
                            このレビューを削除
                        </button>
                        <button onClick={handleEditReview}>
                            レビューを編集する
                        </button>
                    </div>
                </div>
            ) : (
                // レビューが存在しない場合はレビュー投稿ボタンを表示
                <div>
                    <h3>レビューを投稿</h3>
                    <p>まだこの研究室のレビューを投稿していません。</p>
                    <button onClick={handleCreateReview}>
                        レビューを投稿する
                    </button>
                </div>
            )}

            <hr />

            {/* コメント一覧 */}
            <h2>コメント</h2>
            {comments && comments.length > 0 ? (
                <div>
                    {comments.map((comment) => (
                        <div key={comment.id} style={{ border: '1px solid #ccc', padding: '10px', margin: '10px 0' }}>
                            <p><strong>投稿者:</strong> {comment.user?.name || '匿名'}</p>
                            <p><strong>投稿日:</strong> {new Date(comment.created_at).toLocaleDateString()}</p>
                            <p><strong>内容:</strong> {comment.content}</p>
                            
                            {/* ログインしているユーザーが自分のコメントの場合のみ編集・削除ボタンを表示 */}
                            {auth && auth.user && auth.user.id === comment.user_id && (
                                <div>
                                    <button onClick={() => handleEditComment(comment.id)}>
                                        編集
                                    </button>
                                    <button onClick={() => handleDeleteComment(comment.id)}>
                                        削除
                                    </button>
                                </div>
                            )}
                        </div>
                    ))}
                </div>
            ) : (
                <p>まだコメントがありません。</p>
            )}
        </div>
    );
}

7. 動作確認

ここまでできたら、動作確認をしましょう!
例の「ほげほげ研究室」の詳細ページに適当なユーザーでログインした状態でアクセスしてみましょう。

ブックマーク数が0になっていることを確認して、追加!
image.png

image.png

ブックマークの数が1に増えました!
削除しましょう。
image.png

アラートが出るのでOK!!
image.png

OKを押すと...
image.png

消えました!
成功ですね。(≧◇≦)
image.png

ちなみに、ログアウトした状態だと、「追加」ボタンが表示されないようになっていますね。
image.png

8. まとめ・次回予告

お疲れ様でした!
今回もあっさりとした記事になりましたねw

コミット・マージもお忘れなく!

今回はブックマーク機能ということで、追加・削除の両方を実装しました。
しかし、今のところ「マイページ」のような機能がないので、せっかく追加したブックマークの一覧を見ることができず、あまり意味のない状態になってしまっていますね。💦
「マイページ機能」の実装をお待ちください!

しばらく、新しいことがないつまらない回が続いてしまったと思います。
しかし、次回はいよいよ今までにやってこなかった「排他制御・トランザクション」を実装したいと思います!
是非ともお楽しみに!(*^^)v

これまでの記事一覧

--- 要件定義・設計編 ---

--- 環境構築編 ---

--- バックエンド実装編 ---

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?