diff --git a/app/Http/Controllers/AuthController.php b/app/Http/Controllers/AuthController.php index d78d869..4b10153 100644 --- a/app/Http/Controllers/AuthController.php +++ b/app/Http/Controllers/AuthController.php @@ -6,6 +6,7 @@ use App\Http\Requests\LoginRequest; use App\Http\Requests\PasswordResetRequest; use App\Http\Requests\RegisterRequest; use App\Models\Invitation; +use App\Models\Role; use App\Models\User; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; @@ -31,8 +32,13 @@ class AuthController extends Controller ->where('email', $user->email) ->get(); + $viewerRole = Role::query()->where('name', 'VIEWER')->value('id'); + foreach ($invitations as $invitation) { - $user->sharedLists()->attach($invitation->movie_list_id); + $user->sharedLists()->attach( + $invitation->movie_list_id, + ['role_id' => $viewerRole] + ); $invitation->update(['status' => 'accepted']); $invitation->delete(); } diff --git a/app/Http/Controllers/MovieController.php b/app/Http/Controllers/MovieController.php index ad94aa9..384cb7c 100644 --- a/app/Http/Controllers/MovieController.php +++ b/app/Http/Controllers/MovieController.php @@ -10,7 +10,7 @@ use Illuminate\Http\Request; class MovieController extends Controller { - public function __construct(private MovieDbInterface $movieDb) {} + public function __construct() {} /** * Display a listing of the resource. @@ -60,6 +60,6 @@ class MovieController extends Controller { $movies = $movieDb->search($query, $request->input('options', [])); - return response()->json(['results' => $movies]); + return response()->json(['data' => $movies]); } } diff --git a/app/Http/Controllers/MovieListController.php b/app/Http/Controllers/MovieListController.php index c1ec7c2..1ede17b 100644 --- a/app/Http/Controllers/MovieListController.php +++ b/app/Http/Controllers/MovieListController.php @@ -8,6 +8,8 @@ use App\Http\Resources\MovieListResource; use App\Interfaces\MovieDbInterface; use App\Models\Movie; use App\Models\MovieList; +use App\Models\Role; +use App\Models\User; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; use Illuminate\Support\Facades\Auth; @@ -64,7 +66,7 @@ class MovieListController extends Controller $validated = $request->validated(); $movieList->update($validated); - return MovieListResource::make($movieList); + return MovieListResource::make($movieList->load('movies', 'collaborators')); } /** @@ -87,16 +89,34 @@ class MovieListController extends Controller $movieList->movies()->attach($movie); $movieList->load('movies'); - return MovieListResource::make($movieList); + return MovieListResource::make($movieList->load('movies', 'collaborators')); } - public function removeMovie(Request $request, MovieList $movieList, Movie $movie): MovieListResource + public function removeMovie(MovieList $movieList, Movie $movie): MovieListResource { $this->authorize('update', $movieList); $movieList->movies()->detach($movie); $movieList->load('movies'); - return MovieListResource::make($movieList); + return MovieListResource::make($movieList->load('movies', 'collaborators')); + } + + public function updateCollaboratorRole(Request $request, MovieList $movieList, User $collaborator): MovieListResource|JsonResponse + { + $request->validate([ + 'role_id' => 'required|exists:roles,id', + ]); + + $adminRole = Role::query()->where('name', 'ADMIN')->first()?->id; + if (Auth::id() !== $movieList->owner && ! Auth::user()->hasRole($movieList, $adminRole)) { + return response()->json(['message' => 'Unauthorized'], 403); + } + + $movieList->collaborators()->updateExistingPivot($collaborator->getKey(), [ + 'role_id' => $request->input('role_id'), + ]); + + return MovieListResource::make($movieList->load('movies', 'collaborators')); } } diff --git a/app/Http/Controllers/RoleController.php b/app/Http/Controllers/RoleController.php new file mode 100644 index 0000000..d243254 --- /dev/null +++ b/app/Http/Controllers/RoleController.php @@ -0,0 +1,15 @@ +json(['data' => $roles]); + } +} diff --git a/app/Http/Resources/MovieListResource.php b/app/Http/Resources/MovieListResource.php index 0b672ab..e72f637 100644 --- a/app/Http/Resources/MovieListResource.php +++ b/app/Http/Resources/MovieListResource.php @@ -2,10 +2,23 @@ namespace App\Http\Resources; +use App\Models\User; use Auth; +use Illuminate\Database\Eloquent\Collection; use Illuminate\Http\Request; use Illuminate\Http\Resources\Json\JsonResource; +/** + * @property int $id + * @property string $name + * @property bool $is_public + * @property int $owner + * @property User $listOwner + * @property Collection $collaborators + * @property Collection $movies + * + * @method string|null getUserRole(int $user_id) + */ class MovieListResource extends JsonResource { /** @@ -23,9 +36,10 @@ class MovieListResource extends JsonResource 'is_public' => $this->is_public, 'owner' => $this->listOwner->username, 'role' => $this->getRole($this->owner, $user_id), - 'collaborators' => $this->whenLoaded('collaborators', fn () => $this->collaborators->map(fn ($user) => [ + 'collaborators' => $this->whenLoaded('collaborators', fn () => $this->collaborators->map(fn (User $user) => [ + 'id' => $user->getKey(), 'username' => $user->username, - 'role' => $user->pivot->role, + 'role' => $user->pivot->getAttribute('role_id'), ])), 'movies' => $this->whenLoaded('movies'), ]; @@ -34,7 +48,7 @@ class MovieListResource extends JsonResource private function getRole(int $owner_id, int $user_id): ?string { if ($owner_id === $user_id) { - return 'owner'; + return 'OWNER'; } return $this->getUserRole($user_id); diff --git a/app/Models/MovieList.php b/app/Models/MovieList.php index bd422fa..d663eda 100644 --- a/app/Models/MovieList.php +++ b/app/Models/MovieList.php @@ -27,18 +27,19 @@ class MovieList extends Model return $this->belongsToMany(Movie::class); } - public function getUserRole($userId) + public function getUserRole($userId): string { - return $this->collaborators() + $roleId = $this->collaborators() ->where('user_id', $userId) ->first() - ?->pivot - ->role; + ?->pivot->role_id; + + return Role::query()->find($roleId)?->name; } public function collaborators(): BelongsToMany { return $this->belongsToMany(User::class, 'movie_list_user') - ->withPivot('role'); + ->withPivot('role_id'); } } diff --git a/app/Models/Role.php b/app/Models/Role.php new file mode 100644 index 0000000..42bb4d2 --- /dev/null +++ b/app/Models/Role.php @@ -0,0 +1,11 @@ +hasMany(MovieList::class, 'owner'); } + public function hasRole(MovieList $movieList, int $role): bool + { + return $this->sharedLists() + ->wherePivot('movie_list_id', $movieList->id) + ->wherePivot('role_id', $role) + ->exists(); + } + public function sharedLists(): BelongsToMany { - return $this->belongsToMany(MovieList::class)->withPivot('role')->withTimestamps(); + return $this->belongsToMany(MovieList::class) + ->withPivot('role_id') + ->withTimestamps(); } /** diff --git a/database/factories/RoleFactory.php b/database/factories/RoleFactory.php new file mode 100644 index 0000000..2c31cb9 --- /dev/null +++ b/database/factories/RoleFactory.php @@ -0,0 +1,24 @@ + + */ +class RoleFactory extends Factory +{ + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + return [ + 'name' => 'ADMIN', + 'display_name' => 'Administrator', + ]; + } +} diff --git a/database/migrations/2026_03_01_035158_create_roles_table.php b/database/migrations/2026_03_01_035158_create_roles_table.php new file mode 100644 index 0000000..12d4f49 --- /dev/null +++ b/database/migrations/2026_03_01_035158_create_roles_table.php @@ -0,0 +1,30 @@ +id(); + $table->string('display_name'); + $table->string('name')->unique(); + $table->text('description')->nullable(); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('roles'); + } +}; diff --git a/database/migrations/2026_03_02_232801_create_movie_list_user_table.php b/database/migrations/2026_03_02_232801_create_movie_list_user_table.php index 0d95609..93db5f5 100644 --- a/database/migrations/2026_03_02_232801_create_movie_list_user_table.php +++ b/database/migrations/2026_03_02_232801_create_movie_list_user_table.php @@ -15,7 +15,7 @@ return new class extends Migration $table->id(); $table->foreignId('movie_list_id')->constrained()->cascadeOnDelete(); $table->foreignId('user_id')->constrained()->cascadeOnDelete(); - $table->enum('role', ['viewer', 'editor', 'admin'])->default('viewer'); + $table->foreignId('role_id')->constrained()->cascadeOnDelete(); $table->unique(['movie_list_id', 'user_id']); $table->timestamps(); }); diff --git a/database/seeders/DatabaseSeeder.php b/database/seeders/DatabaseSeeder.php index 7ae8f98..2cbdff5 100644 --- a/database/seeders/DatabaseSeeder.php +++ b/database/seeders/DatabaseSeeder.php @@ -2,6 +2,7 @@ namespace Database\Seeders; +use App\Models\Role; use App\Models\User; use Illuminate\Database\Console\Seeds\WithoutModelEvents; use Illuminate\Database\Seeder; @@ -15,11 +16,27 @@ class DatabaseSeeder extends Seeder */ public function run(): void { - // User::factory(10)->create(); + if (config('app.env') === 'local') { + User::factory()->create([ + 'username' => 'testy_mctestface', + 'email' => 'test@example.com', + ]); + } - User::factory()->create([ - 'username' => 'testy_mctestface', - 'email' => 'test@example.com', + Role::factory()->createMany([ + [ + 'name' => 'ADMIN', + 'display_name' => 'Administrator', + 'description' => 'Can make any changes to the list including deleting it. Can also invite other users to collaborate on this list.'], + [ + 'name' => 'EDITOR', + 'display_name' => 'Editor', + 'description' => 'Can add/remove movies from the list.'], + [ + 'name' => 'VIEWER', + 'display_name' => 'Viewer', + 'description' => 'Can view the list, but cannot make any changes.', + ], ]); } } diff --git a/routes/api.php b/routes/api.php index 0549072..28a88f3 100644 --- a/routes/api.php +++ b/routes/api.php @@ -4,6 +4,7 @@ use App\Http\Controllers\AuthController; use App\Http\Controllers\InvitationController; use App\Http\Controllers\MovieController; use App\Http\Controllers\MovieListController; +use App\Http\Controllers\RoleController; use Illuminate\Support\Facades\Route; // Public auth routes @@ -32,4 +33,8 @@ Route::middleware('auth:sanctum')->group(function () { Route::post('/movielists/{movieList}/movies', [MovieListController::class, 'addMovie'])->name('movielists.addMovie'); Route::delete('/movielists/{movieList}/movies/{movie}', [MovieListController::class, 'removeMovie'])->name('movielists.removeMovie'); Route::delete('/movielists/{movieList}', [MovieListController::class, 'destroy'])->name('movielists.destroy'); + Route::patch('/movielists/{movieList}/collaborators/{collaborator}', [MovieListController::class, 'updateCollaboratorRole'])->name('movielists.updateCollaboratorRole'); + + // Roles + Route::get('/roles', [RoleController::class, 'index'])->name('roles.index'); }); diff --git a/tests/Feature/UpdateCollaboratorRoleTest.php b/tests/Feature/UpdateCollaboratorRoleTest.php new file mode 100644 index 0000000..f800019 --- /dev/null +++ b/tests/Feature/UpdateCollaboratorRoleTest.php @@ -0,0 +1,143 @@ +seed(DatabaseSeeder::class); + + $this->adminRole = Role::where('name', 'ADMIN')->first(); + $this->editorRole = Role::where('name', 'EDITOR')->first(); + $this->viewerRole = Role::where('name', 'VIEWER')->first(); + } + + private function makeList(User $owner): MovieList + { + return MovieList::create([ + 'name' => 'Test List', + 'owner' => $owner->getKey(), + 'slug' => 'test-list', + ]); + } + + public function test_role_id_is_required(): void + { + $owner = User::factory()->create(); + $collaborator = User::factory()->create(); + $movieList = $this->makeList($owner); + $movieList->collaborators()->attach($collaborator, ['role_id' => $this->viewerRole->getKey()]); + + $response = $this->actingAs($owner) + ->patchJson("/api/movielists/{$movieList->getKey()}/collaborators/{$collaborator->getKey()}", []); + + $response->assertUnprocessable() + ->assertJsonValidationErrors(['role_id']); + } + + public function test_role_id_must_exist_in_roles_table(): void + { + $owner = User::factory()->create(); + $collaborator = User::factory()->create(); + $movieList = $this->makeList($owner); + $movieList->collaborators()->attach($collaborator, ['role_id' => $this->viewerRole->getKey()]); + + $response = $this->actingAs($owner) + ->patchJson("/api/movielists/{$movieList->getKey()}/collaborators/{$collaborator->getKey()}", [ + 'role_id' => 9999, + ]); + + $response->assertUnprocessable() + ->assertJsonValidationErrors(['role_id']); + } + + public function test_owner_can_update_collaborator_role(): void + { + $owner = User::factory()->create(); + $collaborator = User::factory()->create(); + $movieList = $this->makeList($owner); + $movieList->collaborators()->attach($collaborator, ['role_id' => $this->viewerRole->getKey()]); + + $response = $this->actingAs($owner) + ->patchJson("/api/movielists/{$movieList->getKey()}/collaborators/{$collaborator->getKey()}", [ + 'role_id' => $this->editorRole->getKey(), + ]); + + $response->assertOk(); + $this->assertDatabaseHas('movie_list_user', [ + 'movie_list_id' => $movieList->getKey(), + 'user_id' => $collaborator->getKey(), + 'role_id' => $this->editorRole->getKey(), + ]); + } + + public function test_admin_collaborator_can_update_collaborator_role(): void + { + $owner = User::factory()->create(); + $admin = User::factory()->create(); + $collaborator = User::factory()->create(); + $movieList = $this->makeList($owner); + $movieList->collaborators()->attach($admin, ['role_id' => $this->adminRole->getKey()]); + $movieList->collaborators()->attach($collaborator, ['role_id' => $this->viewerRole->getKey()]); + + $response = $this->actingAs($admin) + ->patchJson("/api/movielists/{$movieList->getKey()}/collaborators/{$collaborator->getKey()}", [ + 'role_id' => $this->editorRole->getKey(), + ]); + + $response->assertOk(); + $this->assertDatabaseHas('movie_list_user', [ + 'movie_list_id' => $movieList->getKey(), + 'user_id' => $collaborator->getKey(), + 'role_id' => $this->editorRole->getKey(), + ]); + } + + public function test_non_admin_collaborator_cannot_update_collaborator_role(): void + { + $owner = User::factory()->create(); + $editor = User::factory()->create(); + $collaborator = User::factory()->create(); + $movieList = $this->makeList($owner); + $movieList->collaborators()->attach($editor, ['role_id' => $this->editorRole->getKey()]); + $movieList->collaborators()->attach($collaborator, ['role_id' => $this->viewerRole->getKey()]); + + $response = $this->actingAs($editor) + ->patchJson("/api/movielists/{$movieList->getKey()}/collaborators/{$collaborator->getKey()}", [ + 'role_id' => $this->editorRole->getKey(), + ]); + + $response->assertForbidden(); + } + + public function test_unrelated_user_cannot_update_collaborator_role(): void + { + $owner = User::factory()->create(); + $collaborator = User::factory()->create(); + $stranger = User::factory()->create(); + $movieList = $this->makeList($owner); + $movieList->collaborators()->attach($collaborator, ['role_id' => $this->viewerRole->getKey()]); + + $response = $this->actingAs($stranger) + ->patchJson("/api/movielists/{$movieList->getKey()}/collaborators/{$collaborator->getKey()}", [ + 'role_id' => $this->editorRole->getKey(), + ]); + + $response->assertForbidden(); + } +}