diff --git a/.gitignore b/.gitignore index b71b1ea..3472759 100644 --- a/.gitignore +++ b/.gitignore @@ -22,3 +22,4 @@ Homestead.json Homestead.yaml Thumbs.db +CLAUDE.md diff --git a/app/Http/Controllers/AuthController.php b/app/Http/Controllers/AuthController.php index 7756775..d78d869 100644 --- a/app/Http/Controllers/AuthController.php +++ b/app/Http/Controllers/AuthController.php @@ -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 diff --git a/app/Http/Controllers/InvitationController.php b/app/Http/Controllers/InvitationController.php index 62a6db7..936a821 100644 --- a/app/Http/Controllers/InvitationController.php +++ b/app/Http/Controllers/InvitationController.php @@ -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()); - /** - * Display the specified resource. - */ - public function show(Invitation $invitation) - { - // + return response()->json(['message' => 'Failed to accept invitation', 'status' => 'failed'], 500); + } + + $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. */ diff --git a/app/Http/Controllers/MovieListController.php b/app/Http/Controllers/MovieListController.php index f10c83f..95da065 100644 --- a/app/Http/Controllers/MovieListController.php +++ b/app/Http/Controllers/MovieListController.php @@ -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); + } /** diff --git a/app/Http/Requests/CreateInvitationRequest.php b/app/Http/Requests/CreateInvitationRequest.php new file mode 100644 index 0000000..c2ff59c --- /dev/null +++ b/app/Http/Requests/CreateInvitationRequest.php @@ -0,0 +1,55 @@ +|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.', + ]; + } +} diff --git a/app/Http/Requests/PasswordResetRequest.php b/app/Http/Requests/PasswordResetRequest.php new file mode 100644 index 0000000..a37f4e1 --- /dev/null +++ b/app/Http/Requests/PasswordResetRequest.php @@ -0,0 +1,31 @@ +|string> + */ + public function rules(): array + { + return [ + 'password' => 'required|string|min:8|confirmed', + 'password_confirmation' => 'string', + 'token' => 'required|string', + 'email' => 'required|email|exists:users,email', + ]; + } +} diff --git a/app/Http/Requests/RegisterRequest.php b/app/Http/Requests/RegisterRequest.php index 8768ffe..0118da9 100644 --- a/app/Http/Requests/RegisterRequest.php +++ b/app/Http/Requests/RegisterRequest.php @@ -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', ]; } } diff --git a/app/Mail/ListCollaboratorInvite.php b/app/Mail/ListCollaboratorInvite.php new file mode 100644 index 0000000..6f1866f --- /dev/null +++ b/app/Mail/ListCollaboratorInvite.php @@ -0,0 +1,65 @@ +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 + */ + public function attachments(): array + { + return []; + } +} diff --git a/app/Models/Invitation.php b/app/Models/Invitation.php index 0ba2004..d9c9d9a 100644 --- a/app/Models/Invitation.php +++ b/app/Models/Invitation.php @@ -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', + ]; } diff --git a/app/Models/MovieList.php b/app/Models/MovieList.php index dc57c45..b27acbe 100644 --- a/app/Models/MovieList.php +++ b/app/Models/MovieList.php @@ -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'); + } } diff --git a/app/Models/User.php b/app/Models/User.php index f070dd1..f978996 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -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); - } } diff --git a/app/Policies/MovieListPolicy.php b/app/Policies/MovieListPolicy.php index 444899a..2d31082 100644 --- a/app/Policies/MovieListPolicy.php +++ b/app/Policies/MovieListPolicy.php @@ -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; } diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 87bbe51..3c1b402 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -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); + }); } } diff --git a/app/Services/OmdbMovieService.php b/app/Services/OmdbMovieService.php index 72e381a..3fe82db 100644 --- a/app/Services/OmdbMovieService.php +++ b/app/Services/OmdbMovieService.php @@ -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(), ]); diff --git a/config/app.php b/config/app.php index 423eed5..8fe6047 100644 --- a/config/app.php +++ b/config/app.php @@ -53,6 +53,7 @@ return [ */ 'url' => env('APP_URL', 'http://localhost'), + 'frontend_url' => env('FRONTEND_URL', 'http://localhost:3000'), /* |-------------------------------------------------------------------------- diff --git a/config/mail.php b/config/mail.php index 522b284..69e359f 100644 --- a/config/mail.php +++ b/config/mail.php @@ -36,7 +36,9 @@ return [ */ 'mailers' => [ - + 'mailgun' => [ + 'transport' => 'mailgun', + ], 'smtp' => [ 'transport' => 'smtp', 'scheme' => env('MAIL_SCHEME'), diff --git a/database/migrations/0001_01_01_000000_create_users_table.php b/database/migrations/0001_01_01_000000_create_users_table.php index e137fbb..a6a0bd4 100644 --- a/database/migrations/0001_01_01_000000_create_users_table.php +++ b/database/migrations/0001_01_01_000000_create_users_table.php @@ -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(); }); diff --git a/database/migrations/2026_02_17_013705_create_invitations_table.php b/database/migrations/2026_02_17_013705_create_invitations_table.php index 765a0c0..d4d9c73 100644 --- a/database/migrations/2026_02_17_013705_create_invitations_table.php +++ b/database/migrations/2026_02_17_013705_create_invitations_table.php @@ -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(); 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 new file mode 100644 index 0000000..0d95609 --- /dev/null +++ b/database/migrations/2026_03_02_232801_create_movie_list_user_table.php @@ -0,0 +1,31 @@ +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'); + } +}; diff --git a/resources/views/email/list-collaborator-invite.blade.php b/resources/views/email/list-collaborator-invite.blade.php new file mode 100644 index 0000000..79f8496 --- /dev/null +++ b/resources/views/email/list-collaborator-invite.blade.php @@ -0,0 +1,7 @@ +
+

Welcome to the fucking show

+ +

You have been invited to collaborate on {{$movieList->name}}

+ Click here to accept + Click here to decline +
diff --git a/routes/api.php b/routes/api.php index be8b0d9..68176d7 100644 --- a/routes/api.php +++ b/routes/api.php @@ -1,19 +1,25 @@ 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');