added support for inviting list collaborators

This commit is contained in:
Edward Tirado Jr 2026-04-03 00:39:37 -05:00
parent cd2c8adaa8
commit 0787b75780
21 changed files with 393 additions and 34 deletions

1
.gitignore vendored
View file

@ -22,3 +22,4 @@
Homestead.json
Homestead.yaml
Thumbs.db
CLAUDE.md

View file

@ -3,11 +3,14 @@
namespace App\Http\Controllers;
use App\Http\Requests\LoginRequest;
use App\Http\Requests\PasswordResetRequest;
use App\Http\Requests\RegisterRequest;
use App\Models\Invitation;
use App\Models\User;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Password;
class AuthController extends Controller
{
@ -15,13 +18,55 @@ class AuthController extends Controller
{
$user = User::create($request->validated());
Auth::login($user);
$request->session()->regenerate();
Password::sendResetLink(['email' => $user->email]);
$this->processAcceptedInvitations($user);
return response()->json($user, 201);
}
private function processAcceptedInvitations(User $user)
{
$invitations = Invitation::query()
->where('status', 'accepted_login_pending')
->where('email', $user->email)
->get();
foreach ($invitations as $invitation) {
$user->sharedLists()->attach($invitation->movie_list_id);
$invitation->update(['status' => 'accepted']);
$invitation->delete();
}
}
public function forgotPassword(Request $request)
{
$request->validate(['email' => 'required|email']);
Password::sendResetLink(['email' => $request->email]);
return response()->json(['message' => 'Password reset link sent!']);
}
public function resetPassword(PasswordResetRequest $request)
{
$updatedUser = null;
$status = Password::reset($request->validated(), function (User $user, string $password) use (&$updatedUser) {
$user->forceFill(['password' => $password])->save();
$updatedUser = $user;
});
if ($status === Password::PASSWORD_RESET && $updatedUser) {
Auth::login($updatedUser);
return response()->json(['message' => 'Password reset successfully.']);
} elseif ($status === Password::INVALID_TOKEN) {
return response()->json(['message' => 'Token expired'], 401);
}
return response()->json(['message' => 'Unable to reset password'], 400);
}
public function login(LoginRequest $request): JsonResponse
{
if (! Auth::attempt($request->validated())) {
@ -29,8 +74,10 @@ class AuthController extends Controller
}
$request->session()->regenerate();
$user = Auth::user();
$this->processAcceptedInvitations($user);
return response()->json(Auth::user());
return response()->json($user);
}
public function logout(Request $request): JsonResponse

View file

@ -2,11 +2,28 @@
namespace App\Http\Controllers;
use App\Http\Requests\CreateInvitationRequest;
use App\Mail\ListCollaboratorInvite;
use App\Models\Invitation;
use Exception;
use Illuminate\Database\Eloquent\ModelNotFoundException;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Str;
use Throwable;
class InvitationController extends Controller
{
private Invitation $invitation;
public function __construct(Invitation $invitation)
{
$this->invitation = $invitation;
}
/**
* Display a listing of the resource.
*/
@ -16,19 +33,32 @@ class InvitationController extends Controller
}
/**
* Store a newly created resource in storage.
* Display a listing of the resource.
*/
public function store(Request $request)
public function accept(Request $request, string $token)
{
//
try {
$invitation = $this->invitation::where('token', $token)->firstOrFail();
} catch (ModelNotFoundException $e) {
return response()->json(['message' => 'Invitation not found', 'status' => 'not_found'], 404);
} catch (Throwable $e) {
Log::error('Failed to accept invitation: '.$e->getMessage());
return response()->json(['message' => 'Failed to accept invitation', 'status' => 'failed'], 500);
}
/**
* Display the specified resource.
*/
public function show(Invitation $invitation)
{
//
$user = Auth::user();
if ($user) {
$user->sharedLists()->attach($invitation->movie_list_id, ['role' => 'viewer']);
$invitation->update(['status' => 'accepted']);
$invitation->delete();
} else {
$invitation->update(['status' => 'accepted_login_pending']);
return response()->json(['message' => 'Unauthorized', 'status' => 'pending'], 401);
}
return response()->json(['message' => 'Invitation accepted', 'status' => 'accepted']);
}
/**
@ -39,6 +69,54 @@ class InvitationController extends Controller
//
}
/**
* Display a listing of the resource.
*/
public function decline()
{
//
}
/**
* Store a newly created resource in storage.
*/
public function store(CreateInvitationRequest $request)
{
$validated = $request->validated();
$invitations = [];
try {
DB::transaction(function () use ($validated, &$invitations) {
foreach ($validated['emails'] as $email) {
$invitations[] = Invitation::create([
'email' => $email,
'movie_list_id' => $validated['movie_list_id'],
'token' => Str::uuid(),
'expires_at' => now()->addDays(Invitation::EXPIRATION_DAYS),
]);
}
});
} catch (Exception $e) {
logger()->error('Failed to create invitation: '.$e->getMessage());
return response()->json(['message' => 'Failed to create invitations.'], 500);
}
foreach ($invitations as $invitation) {
Mail::to($invitation->email)->queue(new ListCollaboratorInvite(Auth::user(), $invitation));
}
return response()->json(['message' => 'Invitation created successfully'], 201);
}
/**
* Display the specified resource.
*/
public function show(Invitation $invitation)
{
//
}
/**
* Remove the specified resource from storage.
*/

View file

@ -9,6 +9,7 @@ use App\Models\Movie;
use App\Models\MovieList;
use Illuminate\Database\Eloquent\ModelNotFoundException;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Str;
class MovieListController extends Controller
@ -18,7 +19,13 @@ class MovieListController extends Controller
*/
public function index()
{
return MovieList::all();
$user = Auth::user();
return response()->json([
'movie_lists' => $user->movieLists,
'shared_lists' => $user->sharedLists,
], 200);
}
/**

View file

@ -0,0 +1,55 @@
<?php
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\Rule;
class CreateInvitationRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*/
public function authorize(): bool
{
return true;
}
/**
* Get the validation rules that apply to the request.
*
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
return [
'emails' => 'required|array',
'emails.*' => [
'required',
'email',
// They haven't been invited to the movie list
Rule::unique('invitations', 'email')
->where('movie_list_id', $this->input('movie_list_id')),
// The user isn't already a collaborator
Rule::notIn(
DB::table('movie_list_user')
->join('users', 'users.id', '=', 'movie_list_user.user_id')
->where('movie_list_id', $this->input('movie_list_id'))
->pluck('users.email')
->push($this->user()->email)
->toArray()
),
],
'movie_list_id' => 'required|exists:movie_lists,id',
];
}
public function messages()
{
return [
'emails.*.unique' => 'The email address is already invited to this movie list.',
'emails.*.not_in' => ':input is already a collaborator.',
];
}
}

View file

@ -0,0 +1,31 @@
<?php
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
class PasswordResetRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*/
public function authorize(): bool
{
return true;
}
/**
* Get the validation rules that apply to the request.
*
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
return [
'password' => 'required|string|min:8|confirmed',
'password_confirmation' => 'string',
'token' => 'required|string',
'email' => 'required|email|exists:users,email',
];
}
}

View file

@ -24,7 +24,6 @@ class RegisterRequest extends FormRequest
return [
'username' => 'required|string|max:255|unique:users',
'email' => 'required|string|email|max:255|unique:users',
'password' => 'required|string|min:8|confirmed',
];
}
}

View file

@ -0,0 +1,65 @@
<?php
namespace App\Mail;
use App\Models\Invitation;
use App\Models\MovieList;
use App\Models\User;
use Illuminate\Bus\Queueable;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;
use Illuminate\Queue\SerializesModels;
class ListCollaboratorInvite extends Mailable
{
use Queueable, SerializesModels;
public MovieList $movieList;
public string $acceptUrl;
public string $declineUrl;
/**
* Create a new message instance.
*/
public function __construct(
public User $list_owner,
public Invitation $invitation,
) {
$this->movieList = MovieList::find($invitation->movie_list_id);
$this->acceptUrl = config('app.frontend_url').'/invitations/'.$invitation->token.'/accept';
$this->declineUrl = config('app.frontend_url').'/invitations/'.$invitation->token.'/decline';
}
/**
* Get the message envelope.
*/
public function envelope(): Envelope
{
return new Envelope(
subject: 'List Collaborator Invite',
);
}
/**
* Get the message content definition.
*/
public function content(): Content
{
return new Content(
view: 'email.list-collaborator-invite',
);
}
/**
* Get the attachments for the message.
*
* @return array<int, \Illuminate\Mail\Mailables\Attachment>
*/
public function attachments(): array
{
return [];
}
}

View file

@ -6,5 +6,13 @@ use Illuminate\Database\Eloquent\Model;
class Invitation extends Model
{
//
const EXPIRATION_DAYS = 7;
protected $fillable = [
'email',
'token',
'movie_list_id',
'status',
'expires_at',
];
}

View file

@ -18,4 +18,9 @@ class MovieList extends Model
{
return $this->belongsToMany(Movie::class);
}
public function collaborators(): BelongsToMany
{
return $this->belongsToMany(User::class, 'movie_list_user');
}
}

View file

@ -4,6 +4,8 @@ namespace App\Models;
// use Illuminate\Contracts\Auth\MustVerifyEmail;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\HasOne;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;
@ -21,7 +23,6 @@ class User extends Authenticatable
protected $fillable = [
'username',
'email',
'password',
];
/**
@ -34,6 +35,21 @@ class User extends Authenticatable
'remember_token',
];
public function profile(): HasOne
{
return $this->hasOne(Profile::class);
}
public function movieLists(): HasMany
{
return $this->hasMany(MovieList::class, 'owner');
}
public function sharedLists(): BelongsToMany
{
return $this->belongsToMany(MovieList::class)->withPivot('role')->withTimestamps();
}
/**
* Get the attributes that should be cast.
*
@ -46,9 +62,4 @@ class User extends Authenticatable
'password' => 'hashed',
];
}
public function profile(): HasOne
{
return $this->hasOne(Profile::class);
}
}

View file

@ -22,7 +22,7 @@ class MovieListPolicy
public function view(User $user, MovieList $movieList): bool
{
if ($movieList->owner === $user->getKey() || $movieList->isPublic) {
if ($movieList->owner === $user->getKey() || $movieList->isPublic || $user->sharedLists->contains($movieList)) {
return true;
}

View file

@ -3,7 +3,9 @@
namespace App\Providers;
use App\Interfaces\MovieDbInterface;
use App\Models\User;
use App\Services\OmdbMovieService;
use Illuminate\Auth\Notifications\ResetPassword;
use Illuminate\Support\ServiceProvider;
class AppServiceProvider extends ServiceProvider
@ -21,6 +23,8 @@ class AppServiceProvider extends ServiceProvider
*/
public function boot(): void
{
//
ResetPassword::createUrlUsing(function (User $user, string $token) {
return config('app.frontend_url')."/auth/reset-password/$token?email=".urlencode($user->email);
});
}
}

View file

@ -82,7 +82,7 @@ class OmdbMovieService implements MovieDbInterface
'plot' => $movieDetails->plot,
'genre' => $movieDetails->genre,
'mpaa_rating' => $movieDetails->mpaaRating,
'critic_scores' => json_encode($movieDetails->criticScores),
'critic_scores' => $movieDetails->criticScores,
'poster' => $movieDetails->poster,
'added_by' => auth()->id(),
]);
@ -169,7 +169,7 @@ class OmdbMovieService implements MovieDbInterface
'plot' => $movieDetails->plot,
'genre' => $movieDetails->genre,
'mpaa_rating' => $movieDetails->mpaaRating,
'critic_scores' => json_encode($movieDetails->criticScores),
'critic_scores' => $movieDetails->criticScores,
'poster' => $movieDetails->poster,
'added_by' => auth()->id(),
]);

View file

@ -53,6 +53,7 @@ return [
*/
'url' => env('APP_URL', 'http://localhost'),
'frontend_url' => env('FRONTEND_URL', 'http://localhost:3000'),
/*
|--------------------------------------------------------------------------

View file

@ -36,7 +36,9 @@ return [
*/
'mailers' => [
'mailgun' => [
'transport' => 'mailgun',
],
'smtp' => [
'transport' => 'smtp',
'scheme' => env('MAIL_SCHEME'),

View file

@ -16,7 +16,7 @@ return new class extends Migration
$table->string('username')->unique();
$table->string('email')->unique();
$table->timestamp('email_verified_at')->nullable();
$table->string('password');
$table->string('password')->nullable();
$table->rememberToken();
$table->timestamps();
});

View file

@ -16,6 +16,7 @@ return new class extends Migration
$table->string('email');
$table->string('token')->unique();
$table->foreignId('movie_list_id')->constrained()->cascadeOnDelete();
$table->enum('status', ['pending', 'accepted_login_pending', 'accepted', 'declined'])->default('pending');
$table->dateTime('expires_at');
$table->softDeletes();
$table->timestamps();

View file

@ -0,0 +1,31 @@
<?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('movie_list_user', function (Blueprint $table) {
$table->id();
$table->foreignId('movie_list_id')->constrained()->cascadeOnDelete();
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
$table->enum('role', ['viewer', 'editor', 'admin'])->default('viewer');
$table->unique(['movie_list_id', 'user_id']);
$table->timestamps();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('movie_list_user');
}
};

View file

@ -0,0 +1,7 @@
<div>
<h1>Welcome to the fucking show</h1>
<p>You have been invited to collaborate on {{$movieList->name}}</p>
<a href="{{ $acceptUrl }}">Click here to accept</a>
<a href="{{ $declineUrl }}">Click here to decline</a>
</div>

View file

@ -1,19 +1,25 @@
<?php
use App\Http\Controllers\AuthController;
use App\Http\Controllers\InvitationController;
use App\Http\Controllers\MovieController;
use App\Http\Controllers\MovieListController;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Route;
// Public auth routes
Route::post('/register', [AuthController::class, 'register']);
Route::post('/login', [AuthController::class, 'login']);
Route::post('/register', [AuthController::class, 'register'])->name('auth.register');
Route::post('/login', [AuthController::class, 'login'])->name('auth.login');
Route::post('/reset-password', [AuthController::class, 'resetPassword'])->name('auth.reset-password');
Route::post('/forgot-password', [AuthController::class, 'forgotPassword'])->name('auth.forgot-password');
Route::get('/invitations/{token}/accept', [InvitationController::class, 'accept'])->name('invitations.accept');
Route::get('/invitations/{token}/decline', [InvitationController::class, 'decline'])->name('invitations.decline');
// Authenticated routes
Route::middleware('auth:sanctum')->group(function () {
Route::post('/logout', [AuthController::class, 'logout']);
Route::get('/user', fn (Request $request) => $request->user());
Route::post('/logout', [AuthController::class, 'logout'])->name('auth.logout');
// Invitations
Route::post('/invitations', [InvitationController::class, 'store'])->name('invitations.store');
// Movies
Route::get('/movies/search/{query}', [MovieController::class, 'search'])->name('movies.search');