Thanks to visit codestin.com
Credit goes to qiita.com

5
1

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アプリケーション開発に挑戦してみた!(その19)

0. 初めに

こんにちは!
実務未経験エンジニアの僕がWebアプリケーションを開発する方法を一から解説しているシリーズでございます。

バックエンド実装編第13回目の本日は、管理者アカウントを実装します!

管理者とは?

その名の通り、アプリの管理者です。
本シリーズでいうと、今実装をなさっているあなた自身です!

管理者は、アプリ内において他の一般のユーザーにはできない行動をとることができます。
これを管理者権限と呼ぶことにします。

今日実装する管理者権限は以下の通りです!

  • 大学削除機能
  • 学部削除機能
  • 研究室削除機能
  • コメント削除機能

1. ブランチ運用

いつも通り、develop ブランチを最新化して、新たにブランチを切って作業しましょう。
作業が終わったら、毎度のことならがコミット・プッシュをお忘れなくです。(*´ω`)

ブランチ名: feature/17-admin

2. 訂正: データベース設計

またもや(←何度目だよ)、データベース設計で修正の必要なところがあるので見ていきましょう!

image.png

上の図の通り、users テーブルに管理者かどうかを区別するための is_admin を追加しました。

マイグレーションを追加する

テーブル設計の変更に伴って、新しいカラムを追加するためのマイグレーションファイルを作成し、実行します。

実行コマンド

/var/www
$ php artisan make:migration add_is_admin_to_users_table
\project-root\src\database\migrations\2025_08_20_225059_add_is_admin_to_users_table.php
<?php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration
{
    /**
     * Run the migrations.
     */
    public function up(): void
    {
        Schema::table('users', function (Blueprint $table) {
            $table->boolean('is_admin')->default(false);
        });
    }

    /**
     * Reverse the migrations.
     */
    public function down(): void
    {
        Schema::table('users', function (Blueprint $table) {
            $table->dropColumn('is_admin');
        });
    }
};

過去に users テーブルへ deleted_at カラムを追加したことがありましたが、その時と同じですね。

出来たら、実行しましょう。
実行コマンド

/var/www
$ php artisan migrate

モデルを修正する

カラムが追加されたので、Userモデルの $fillable にカラムを追加します。

\project-root\src\app\Models\User.php
<?php

namespace App\Models;

// use Illuminate\Contracts\Auth\MustVerifyEmail;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;

class User extends Authenticatable
{
    use HasFactory, Notifiable, SoftDeletes;

    /**
     * The attributes that are mass assignable.
     *
     * @var array<int, string>
     */
    protected $fillable = [
        'name',
        'email',
        'password',
        'is_admin', // 追加: 管理者フラグ
    ];

ついでに、後で認可用に使いたいので、管理者かどうかを判定するための is_admin() メソッドもあらかじめ追加しておきましょう。

\project-root\src\app\Models\User.php
    // 追加: 管理者かどうかを判定するメソッド
    public function is_admin()
    {
        return $this->is_admin;
    }

さらに、削除対象の各モデルに対して、論理削除が実現できるように記述を追加します。

大学モデル

\project-root\src\app\Models\University.php
<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;

class University extends Model
{
    use SoftDeletes; // 追加: 論理削除

    protected $fillable = ['name'];

    // 追加
    protected static function boot()
    {
        parent::boot();

        static::deleting(function ($university) {
            // 論理削除時に関連する学部も論理削除
            $university->faculties()->get()->each->delete();
        });
    }

    // リレーションの定義
    // ユーザーとのリレーション(多対多)
    // 中間テーブル名を明示的に指定
    public function users()
    {
        return $this->belongsToMany(User::class, 'university_edit_histories')->withTimestamps();
    }

    // 学部とのリレーション(一対多)
    public function faculties()
    {
        return $this->hasMany(Faculty::class);
    }
}

学部モデル

\project-root\src\app\Models\Faculty.php
<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;

class Faculty extends Model
{
    use SoftDeletes; // 追加: 論理削除

    protected $fillable = [
        'name', 'university_id'
    ];

    protected static function boot()
    {
        parent::boot();

        static::deleting(function ($faculty) {
            // 論理削除時に関連する研究室も論理削除
            $faculty->labs()->get()->each->delete();
        });
    }

    // リレーションの定義
    // ユーザーとのリレーション(多対多)
    // 中間テーブル名を明示的に指定
    public function users()
    {
        return $this->belongsToMany(User::class, 'faculty_edit_histories')->withTimestamps();
    }

    // 大学とのリレーション(多対一)
    public function university()
    {
        return $this->belongsTo(University::class);
    }

    // 研究室とのリレーション(一対多)
    public function labs()
    {
        return $this->hasMany(Lab::class);
    }
}

研究室

\project-root\src\app\Models\Lab.php
<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes; // 追加: 論理削除

class Lab extends Model
{
    use SoftDeletes; // 追加: 論理削除

    protected $fillable = [
        'name',
        'faculty_id',
    ];

    // リレーションの定義
    // ユーザーとのリレーション(多対多)
    // 中間テーブル名を明示的に指定
    public function users()
    {
        return $this->belongsToMany(User::class, 'lab_edit_histories')->withTimestamps();
    }

    // 学部とのリレーション(多対一)
    public function faculty()
    {
        return $this->belongsTo(Faculty::class);
    }

    // レビューとのリレーション(一対多)
    public function reviews()
    {
        return $this->hasMany(Review::class);
    }

    // コメントとのリレーション(一対多)
    public function comments()
    {
        return $this->hasMany(Comment::class);
    }

    // ブックマークとのリレーション(一対多)
    public function bookmarks()
    {
        return $this->hasMany(Bookmark::class);
    }
}

なぜ、このような面倒なことをするのかと言いますと...
論理削除の場合、手動で設定しておかないとカスケード削除が自動で実行されないから
です!

「カスケード削除」という言葉はあまり一般的ではないかもしれませんね...

普段マイグレーションファイルで設定しているやつです!

\project-root\src\database\migrations\2025_05_30_072525_create_faculties_table.php
// これ!
$table->foreignId('university_id')->constrained('universities')->onDelete('cascade');

つまり、外部キーで指定しているテーブルのキーに対応しているレコードが削除されたときに、自動的に自分自身も削除することです。
この場合だと、リレーションを組んでいる大学が削除されたときに学部自信も一緒に削除するように設定していました。

分かりにくければ他の例:
YouTubeの動画を削除すれば、その動画に投稿されたコメントも同時に削除される。

しかし、調べた感じだと、これは物理削除の場合にしか自動でやってくれないみたいなんですよね。

なので、先ほどのような記述をすることで、論理削除の場合でもカスケード削除ができるというわけです。

では、中身も見ていきましょう。

この部分は、「論理削除をやるぜ」という宣言だと思ってください。

\project-root\src\app\Models\University.php
use SoftDeletes;

次に追加された、boot() メソッドです。
boot() メソッドはLaravelのEloquentに標準搭載されている関数で、中身を書く(正しくはオーバーライドする)ことで、イベント発生に対してコールバック関数を定義できます。

...何言ってんの??

よし、まず、Eloquentはデータベースへの処理を簡単にしてくれるものですな。
Laravelのモデルには、このEloquentが備わっています。

次に、イベントですが、ここでは、モデルに対して起きる出来事です。
具体的には、creating, updating, deleting です。

んで、コールバック関数は、関数の引数にすることで呼び出される関数でしたね!(^_-)-☆

以下の大学モデルの場合を見てみましょう。
deleting() イベントメソッドの引数にコールバック関数として、リレーションを組んでいる学部を削除する記述がされています。

\project-root\src\app\Models\University.php
    protected static function boot()
    {
        parent::boot();

        static::deleting(function ($university) {
            // 論理削除時に関連する学部も論理削除
            $university->faculties()->get()->each->delete();
        });
    }

要するに、「大学が削除されたら、学部も削除する」ってことです!(≧◇≦)

※以下は、若干細かい話なので飛ばしてもらっても大丈夫ですw
Q. protected って何?
A. アクセス権っていうやつです。アプリの機能自体には影響はないのですが、開発するうえでのセキュリティ面で大事になってくる感じかなぁと。

public 宣言されたクラスのメンバーには、どこからでもアクセス可能です。 protected 宣言されたメンバーには、 そのクラス自身、そのクラスを継承したクラス、および親クラスからのみアクセスできます。 private 宣言されたメンバーには、そのメンバーを定義したクラスからのみアクセスできます。

Q. static って何?
A. 静的メソッド などを定義するときに付けるやつです。雑な説明ですが、静的メソッドというのは、インスタンス化しなくても使えるメソッドのことだと思ってください。

普通は、クラスの中にメソッドを書いた場合、それをインスタンス化しなければ、メソッドは使えません。
これまでに登場した例だと、大学コントローラーの store() メソッドがありましたね。

\project-root\src\app\Http\Controllers\UniversityController.php
    public function store(Request $request)
    {
        $this->authorize('create', University::class);
        
        $validated = $request->validate([
            'name' => 'required|string|max:50|unique:universities,name',
        ]);

        $university = new University(); // <-ここでモデルクラスをインスタンス化
        $university->name = $validated['name'];
        $university->save();

        $userId = $request->user()->id;
        $university->users()->attach($userId);

        return redirect()->route('faculties.index', ['university' => $university])->with('success', '大学が作成されました。'); // 修正: リダイレクト先を変更
    }

静的メソッドは、:: で呼び出せます。
これは、phpにもともとあるもので、同様に :: で呼び出せるLaravelのファサードとは異なるものですが、この説明をするとさすがに長いしややこしいので割愛しますw

今回の例だと、parent::boot() のようにすることで、呼び出せます。

Q. じゃあ、parant ってなんだよ。
A. phpでは、クラスの継承元(親クラス)を parant として呼び出すことができます。
復習になりますが、Laravelのモデルはすべて、おおもとの Model クラスを継承しています。

\project-root\src\app\Models\University.php
class University extends Model

Q. そもそも、parent::boot() って必要?
A. それな!正直僕もよくわからないです。(笑)
ないとうまく動かな見たいです。
調べてみてわかったらこの下に追記しますね。(>_<)

管理者ユーザーを作成する

モデルの説明が長くなりましたが、とりあえず一人管理者ユーザーを作成してみましょう。
今回は、これまで使ってこなかったTinkerを用いてみたいと思います。

TinkerはLaravelの機能の一つで、REPLというものの一つです。
REPLは、対話型の実行方法と呼ばれており、ユーザーの入力を即時に実行結果を出力してくれます。

以下のコマンドでLaravel Tinkerが起動します。
実行コマンド

/var/www
$ php artisan tinker

何やら、何かを入力せよと言わんばかりの画面になります。
image.png
ここにLaravelのコードを書くと実行結果をその下に表示してくれます。
試しに、以下のDBクエリビルダをコピペして実行してみましょう。

use App\Models\User;
use Illuminate\Support\Facades\Hash;

User::create([
    'name' => '管理者1',
    'email' => '[email protected]',
    'password' => Hash::make('password'),
    'is_admin' => true,
]);

入力して、「Enter」キーを押すと以下のようになります。
image.png
無事にモデルを通じて、ユーザーを一人作成することに成功したようです。
is_admin もしっかり true で登録されています。
パスワードは分かりやすいように、これまでと同じく 'password' にしておきました。

このようにTinkerを用いることで、データベースの操作はもちろん、デバッグなどにも使えるので便利だそうです!

今までは、Laravelのコードをどこかのファイルに書き込んで、保存して、$ npm run dev でブラウザの画面でいろいろ手を動かして挙動を確認したり、dd() 関数を使って実行結果を確認したりしました。
Tinkerコマンドライン上でコードの実行結果をすぐに見ることができるので使い勝手が良いと感じる方も多いようです。

以下の記事にいろいろとまとめられているので、興味がある方は是非読んでみてください。

操作が終わったら、q と入力(quit: やめるの意味)して、「Enter」を押すと、Tinker状態から抜けられます。

抜けられたら、ブラウザの画面で実際に今のアカウントでログインできるか確認しましょう。
image.png
image.png

問題なさそうです!
皆さんはできましたか?

3. コントローラー作成

続いて、コントローラーを作成しましょう。

実行コマンド

/var/www
$ php artisan make:controller Admin/AdminController

※普段と少し違っていて、Admin/ フォルダを作って、その中に AdminController.php を作成しています。
これにより、名前空間を他のコントローラーと区別でき、役割が明確になるというメリットが生まれます!

\project-root\src\app\Http\Controllers\Admin\AdminController.php
<?php

namespace App\Http\Controllers\Admin; // 名前空間が他のコントローラーと異なり、整理されている

use App\Http\Controllers\Controller;
use App\Models\Comment;
use App\Models\Faculty;
use App\Models\Lab;
use App\Models\University;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Http\Request;
use Inertia\Inertia;

class AdminController extends Controller
{
    use AuthorizesRequests;
    
    public function destroyUniversity(University $university)
    {
        // 認可チェック
        $this->authorize('delete', University::class);

        $university->delete();
        return redirect()->route('labs.home')->with('success', '大学が削除されました。');
    }

    public function destroyFaculty(Faculty $faculty)
    {
        // 認可チェック
        $this->authorize('delete', Faculty::class);

        $faculty->delete();
        return redirect()->route('labs.home')->with('success', '学部が削除されました。');
    }

    public function destroyLab(Lab $lab)
    {
        // 認可チェック
        $this->authorize('delete', Lab::class);

        $lab->delete();
        return redirect()->route('labs.home')->with('success', '研究室が削除されました。');
    }

    public function destroyComment(Comment $comment)
    {
        // 認可チェック
        $this->authorize('delete', $comment);

        $comment->delete();
        return redirect()->route('labs.home')->with('success', 'コメントが削除されました。');
    }
}

大学・学部・研究室・コメントの削除機能を担うメソッドを作りました。

4. 認可を設定

各ポリシーに削除に関する認可を設定するメソッドを追加しました。
コメントに関しては、すでに該当するメソッドがあるため、論理演算子を使って既存のものを修正してみました!

大学削除

\project-root\src\app\Policies\UniversityPolicy.php
    // 追加: ユーザーが大学を削除できるかどうかを判定
    public function delete(User $user)
    {
        // 大学を削除できるのは管理者のみ
        return $user->is_admin();
    }

学部削除

\project-root\src\app\Policies\FacultyPolicy.php
    // 追加: ユーザーが学部を削除できるかどうかを判定
    public function delete(User $user)
    {
        // 学部を削除できるのは管理者のみ
        return $user->is_admin();
    }

研究室削除

\project-root\src\app\Policies\LabPolicy.php
    // 追加: ユーザーが研究室を削除できるかどうかを判定
    public function delete(User $user)
    {
        // 研究室を削除できるのは管理者のみ
        return $user->is_admin();
    }

コメント削除

\project-root\src\app\Policies\CommentPolicy.php
    // 修正: ユーザーがコメントを削除できるかどうかを判定
    public function delete(User $user, Comment $comment)
    {
        // 投稿した本人もしくは管理者のみ削除可能
        return $user->id === $comment->user_id || $user->is_admin();
    }

6. ルーティング追加

admin グループを作って、それをさらに auth ミドルウェアの中に追加します。
これで、ログインしているユーザーかつ管理者だけに絞ったルーティングが実現できます。

Route::middleware('auth')->group(function () {
    Route::get('/profile', [ProfileController::class, 'edit'])->name('profile.edit');
    Route::patch('/profile', [ProfileController::class, 'update'])->name('profile.update');
    Route::delete('/profile', [ProfileController::class, 'destroy'])->name('profile.destroy');

    // レビュー関連

    // 大学関連

    // 学部関連
    
    // 研究室関連

    // コメント関連

    // ブックマーク関連

    // マイページ関連

    // 追加: 管理者用ルート
    Route::prefix('admin')->name('admin.')->group(function () {
        Route::delete('/universities/{university}', [AdminController::class, 'destroyUniversity'])->name('universities.destroy');
        Route::delete('/faculties/{faculty}', [AdminController::class, 'destroyFaculty'])->name('faculties.destroy');
        Route::delete('/labs/{lab}', [AdminController::class, 'destroyLab'])->name('labs.destroy');
        Route::delete('/comments/{comment}', [AdminController::class, 'destroyComment'])->name('comments.destroy');
    });
});

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

最後に、これまで作成してきたReactコンポーネントにおいて、管理者でログインしているときだけ削除ボタンが表示されるように修正していきたいと思います。

大学削除

\project-root\src\resources\js\Pages\Faculty\Index.jsx
import React from 'react';
import { Head, Link, usePage, router } from '@inertiajs/react';

export default function Index() {
    const { faculties, university, auth } = usePage().props;

    // 大学削除のハンドラー
    const handleDeleteUniversity = () => {
        if (confirm(`本当に「${university.name}」を削除しますか?この操作は取り消せません。`)) {
            router.delete(route('admin.universities.destroy', university.id), {
                onSuccess: () => {
                    console.log('大学が削除されました');
                },
                onError: (errors) => {
                    console.error('削除エラー:', errors);
                    alert('削除に失敗しました');
                }
            });
        }
    };

    return (
        <>
            <Head title={`${university.name} - 学部一覧`} />
            
            <div>
                <h1>{university.name}</h1>
                
                {/* 大学編集ボタン */}
                <div>
                    <Link href={route('university.edit', university.id)}>
                        <button>大学を編集</button>
                    </Link>
                </div>
                
                {/* 管理者専用: 大学削除ボタン */}
                {auth.user?.is_admin && (
                    <div style={{ marginTop: '10px' }}>
                        <button 
                            onClick={handleDeleteUniversity}
                            style={{
                                backgroundColor: '#dc2626',
                                color: 'white',
                                padding: '8px 16px',
                                border: 'none',
                                borderRadius: '4px',
                                cursor: 'pointer'
                            }}
                            onMouseOver={(e) => e.target.style.backgroundColor = '#b91c1c'}
                            onMouseOut={(e) => e.target.style.backgroundColor = '#dc2626'}
                        >
                            大学を削除(管理者)
                        </button>
                    </div>
                )}
                
                {/* 編集履歴ボタン */}
                <div>
                    <Link href={route('university.history', university.id)}>
                        <button>編集履歴を見る</button>
                    </Link>
                </div>
                
                {/* 学部作成ボタン */}
                <div>
                    <Link href={route('faculty.create', university.id)}>
                        <button>学部を作成</button>
                    </Link>
                </div>
                
                {/* 学部一覧 */}
                <div>
                    {faculties.length > 0 ? (
                        <div>
                            {faculties.map((faculty) => (
                                <div key={faculty.id}>
                                    <Link href={route('labs.index', { university: university.id, faculty: faculty.id })}>
                                        <h3>{faculty.name}</h3>
                                    </Link>
                                </div>
                            ))}
                        </div>
                    ) : (
                        <p>学部がまだありません。</p>
                    )}
                </div>
            </div>
        </>
    );
}

学部削除

\project-root\src\resources\js\Pages\Lab\Index.jsx
import React from 'react';
import { Head, Link, usePage, router } from '@inertiajs/react';

export default function Index({ labs, faculty }) {
  const { auth } = usePage().props;

  // 学部削除のハンドラー
  const handleDeleteFaculty = () => {
    if (confirm(`本当に「${faculty.name}」を削除しますか?この操作は取り消せません。`)) {
      router.delete(route('admin.faculties.destroy', faculty.id), {
        onSuccess: () => {
          console.log('学部が削除されました');
        },
        onError: (errors) => {
          console.error('削除エラー:', errors);
          alert('削除に失敗しました');
        }
      });
    }
  };

  return (
    <div>
      <Head title={`${faculty.name} - 研究室一覧`} />
      <h1>{faculty.university.name} {faculty.name} - 研究室一覧</h1>
      
      {/* 学部一覧に戻るボタン */}
      <div>
        <Link href={route('faculties.index', faculty.university.id)}>
          <button>学部一覧に戻る</button>
        </Link>
      </div>
      
      {/* 学部編集ボタン */}
      <div>
        <Link href={route('faculty.edit', faculty.id)}>
          <button>学部を編集</button>
        </Link>
      </div>
      
      {/* 管理者専用: 学部削除ボタン */}
      {auth.user?.is_admin && (
        <div style={{ marginTop: '10px' }}>
          <button 
            onClick={handleDeleteFaculty}
            style={{
              backgroundColor: '#dc2626',
              color: 'white',
              padding: '8px 16px',
              border: 'none',
              borderRadius: '4px',
              cursor: 'pointer'
            }}
            onMouseOver={(e) => e.target.style.backgroundColor = '#b91c1c'}
            onMouseOut={(e) => e.target.style.backgroundColor = '#dc2626'}
          >
            学部を削除(管理者)
          </button>
        </div>
      )}
      
      {/* 編集履歴ボタン */}
      <div>
        <Link href={route('faculty.history', faculty.id)}>
          <button>編集履歴を見る</button>
        </Link>
      </div>
      
      {/* 研究室作成ボタン */}
      <div>
        <Link href={route('lab.create', faculty.id)}>
          <button>研究室を作成</button>
        </Link>
      </div>
      <div>
        {labs.length > 0 ? (
          labs.map((lab) => (
            <div key={lab.id}>
              <Link href={route('labs.show', lab.id)}>
                <p>{lab.name}</p>
              </Link>
            </div>
          ))
        ) : (
          <p>研究室がありません。</p>
        )}
      </div>
    </div>
  );
}

研究室・コメント削除

\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 handleAdminDeleteComment = (commentId) => {
        if (confirm('管理者権限でこのコメントを削除してもよろしいですか?')) {
            router.delete(route('admin.comments.destroy', { comment: commentId }), {
                onSuccess: () => {
                    alert('コメントが削除されました(管理者)。');
                },
                onError: (error) => {
                    alert('コメントの削除に失敗しました。');
                }
            });
        }
    };

    // 研究室削除(管理者専用)
    const handleDeleteLab = () => {
        if (confirm(`本当に「${lab.name}」を削除しますか?この操作は取り消せません。`)) {
            router.delete(route('admin.labs.destroy', lab.id), {
                onSuccess: () => {
                    console.log('研究室が削除されました');
                },
                onError: (errors) => {
                    console.error('削除エラー:', errors);
                    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>
            
            {/* 管理者専用: 研究室削除ボタン */}
            {auth?.user?.is_admin && (
                <div style={{ marginTop: '10px' }}>
                    <button 
                        onClick={handleDeleteLab}
                        style={{
                            backgroundColor: '#dc2626',
                            color: 'white',
                            padding: '8px 16px',
                            border: 'none',
                            borderRadius: '4px',
                            cursor: 'pointer'
                        }}
                        onMouseOver={(e) => e.target.style.backgroundColor = '#b91c1c'}
                        onMouseOut={(e) => e.target.style.backgroundColor = '#dc2626'}
                    >
                        研究室を削除(管理者)
                    </button>
                </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 && (
                                <div>
                                    {/* 自分のコメントの場合は編集・削除ボタン */}
                                    {auth.user.id === comment.user_id && (
                                        <>
                                            <button onClick={() => handleEditComment(comment.id)}>
                                                編集
                                            </button>
                                            <button onClick={() => handleDeleteComment(comment.id)}>
                                                削除
                                            </button>
                                        </>
                                    )}
                                    
                                    {/* 管理者の場合は他人のコメントも削除可能 */}
                                    {auth.user.is_admin && auth.user.id !== comment.user_id && (
                                        <button 
                                            onClick={() => handleAdminDeleteComment(comment.id)}
                                            style={{
                                                backgroundColor: '#dc2626',
                                                color: 'white',
                                                padding: '4px 8px',
                                                border: 'none',
                                                borderRadius: '4px',
                                                cursor: 'pointer',
                                                marginLeft: '5px'
                                            }}
                                        >
                                            削除(管理者)
                                        </button>
                                    )}
                                </div>
                            )}
                        </div>
                    ))}
                </div>
            ) : (
                <p>まだコメントがありません。</p>
            )}
        </div>
    );
}

6. 動作確認

大学削除

まずは、先ほど作った管理者アカウントでログインしてみましょう。
image.png
image.png

以下にアクセスして、荒木市立大学を削除してみます。
http://localhost/universities/3/faculties
image.png
image.png

OKを押すと、トップページにリダイレクトされました。
image.png
DBの方を見てみると...
image.png
しっかりと、deleted_at のカラムに日付が入っています。
※僕は一度失敗してやり直しているので、idが少し違いますが、皆さんは気にしないでください。

学部削除

次に、学部を削除してみましょう。
以下にアクセスして、工学部を開きます。
http://localhost/faculty/4/labs

おや...
エラーのようです。
image.png

おそらく、論理削除ができるようにモデルを修正した一方で、データベースのカラムの方には deleted_at がないので不一致が発生してしまっているのだと思います。

解決方法
本当は、新しく deleted_at カラムを追加するマイグレーションファイルを作成して実行するのがセオリーですが、今回は既存のマイグレーションファイルを修正して、データを入れなしちゃいましょう。(笑)

修正後マイグレーションファイル

\project-root\src\database\migrations\2025_05_30_072525_create_faculties_table.php
<?php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration
{
    /**
     * Run the migrations.
     */
    public function up(): void
    {
        Schema::create('faculties', function (Blueprint $table) {
            $table->id();
            $table->foreignId('university_id')->constrained('universities')->onDelete('cascade');
            $table->string('name');
            $table->timestamps();
            $table->softDeletes(); // これを忘れていました。追加しておいてください。
            $table->unsignedBigInteger('version')->default(1);
        });
    }

    /**
     * Reverse the migrations.
     */
    public function down(): void
    {
        Schema::dropIfExists('faculties');
    }
};

研究室の方もどうやら忘れていたみたいなので、ついでに修正しておきましょう。

\project-root\src\database\migrations\2025_05_30_074257_create_labs_table.php
<?php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration
{
    /**
     * Run the migrations.
     */
    public function up(): void
    {
        Schema::create('labs', function (Blueprint $table) {
            $table->id();
            $table->foreignId('faculty_id')->constrained('faculties')->onDelete('cascade');
            $table->string('name');
            $table->text('description')->nullable();
            $table->text('url')->nullable();
            $table->text('professor_url')->nullable();
            $table->unsignedTinyInteger('gender_ratio_male');
            $table->unsignedTinyInteger('gender_ratio_female');
            $table->timestamps();
            $table->softDeletes(); // こっちも忘れていました。追加してください。(笑)
            $table->unsignedBigInteger('version')->default(1); // 追加
        });
    }

    /**
     * Reverse the migrations.
     */
    public function down(): void
    {
        Schema::dropIfExists('labs');
    }
};

実行コマンド

/var/www
$ php artisan migrate:fresh --seed

くれぐれも本番環境で実行しないことだけ注意してください。(笑)

気を取り直して、動作確認を再開しましょう!(*´ω`)
※上のコマンドを実行すると当然先ほど作成した管理者のアカウントも消えます。
よって、もう一度先Tinkerで作成して、ログインしなおしてください。

image.png

image.png

image.png

ログインして、工工学部を削除するとそれに紐づいた研究室もすべて削除されたため、トップページに研究室が何も表示されなくなってしまいましたね。(笑)
一応DBの方も見て、対象の学部と研究室の deleted_at カラムに日付が入っているかを確認してください。
image.png

image.png

続いて、研究室及びコメントの削除をしたいのですが、テストデータの研究室はすべて工学部に属していたため、今のですでに全部消えてしまったためできません。
よって、新しい研究室を別の学部で作り直して、それを削除しましょうか。
ログインしているユーザーは管理者のままで大丈夫です。

以下にアクセスして、理学部に対して適当な研究室を作成してみましょう。
http://localhost/faculties/3/labs/create

image.png

コメントがないので、作成しましょう。
image.png

image.png

image.png

一応、他の一般ユーザーでログインしてコメントを投稿してみました。
image.png

このように自分のコメントしか編集・削除できません。
一方で、管理者でログインしなおしてみると、以下のように自分以外が投稿したコメントも削除できます。
image.png

image.png

image.png

image.png

さらに、研究室を削除してみましょう。
image.png

image.png

image.png

問題なさそうです!(多分)

7. まとめ・次回予告

今回は、Usersテーブルに管理者かどうかを識別する is_admin というカラムを追加することで、管理者のみに許される大学・学部・研究室・コメントの削除機能を実装しました。

管理者の作成には、今回初めてのTinkerという技術を使いました。

次回は、コメント以外のこれらの削除依頼とそれを管理者に通知する機能を作成していきたいと思います!
次回の機能ができたら、バックエンドの機能としては、ひとまずソーシャルログインのみとなります!
ゴールは近いはずです。
最後まで頑張っていきましょう。(≧◇≦)

これまでの記事一覧

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

--- 環境構築編 ---

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

参考

5
1
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
5
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?