Merge pull request 'added support for inviting list collaborators' (#2) from movielist-invitations into main
Reviewed-on: tiradoe/movie-night-api-laravel#2
This commit is contained in:
commit
985f339725
22 changed files with 470 additions and 50 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -22,3 +22,4 @@
|
||||||
Homestead.json
|
Homestead.json
|
||||||
Homestead.yaml
|
Homestead.yaml
|
||||||
Thumbs.db
|
Thumbs.db
|
||||||
|
CLAUDE.md
|
||||||
|
|
|
||||||
|
|
@ -3,11 +3,14 @@
|
||||||
namespace App\Http\Controllers;
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
use App\Http\Requests\LoginRequest;
|
use App\Http\Requests\LoginRequest;
|
||||||
|
use App\Http\Requests\PasswordResetRequest;
|
||||||
use App\Http\Requests\RegisterRequest;
|
use App\Http\Requests\RegisterRequest;
|
||||||
|
use App\Models\Invitation;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Support\Facades\Auth;
|
use Illuminate\Support\Facades\Auth;
|
||||||
|
use Illuminate\Support\Facades\Password;
|
||||||
|
|
||||||
class AuthController extends Controller
|
class AuthController extends Controller
|
||||||
{
|
{
|
||||||
|
|
@ -15,13 +18,55 @@ class AuthController extends Controller
|
||||||
{
|
{
|
||||||
$user = User::create($request->validated());
|
$user = User::create($request->validated());
|
||||||
|
|
||||||
Auth::login($user);
|
Password::sendResetLink(['email' => $user->email]);
|
||||||
|
$this->processAcceptedInvitations($user);
|
||||||
$request->session()->regenerate();
|
|
||||||
|
|
||||||
return response()->json($user, 201);
|
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
|
public function login(LoginRequest $request): JsonResponse
|
||||||
{
|
{
|
||||||
if (! Auth::attempt($request->validated())) {
|
if (! Auth::attempt($request->validated())) {
|
||||||
|
|
@ -29,8 +74,10 @@ class AuthController extends Controller
|
||||||
}
|
}
|
||||||
|
|
||||||
$request->session()->regenerate();
|
$request->session()->regenerate();
|
||||||
|
$user = Auth::user();
|
||||||
|
$this->processAcceptedInvitations($user);
|
||||||
|
|
||||||
return response()->json(Auth::user());
|
return response()->json($user);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function logout(Request $request): JsonResponse
|
public function logout(Request $request): JsonResponse
|
||||||
|
|
|
||||||
|
|
@ -2,11 +2,28 @@
|
||||||
|
|
||||||
namespace App\Http\Controllers;
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
|
use App\Http\Requests\CreateInvitationRequest;
|
||||||
|
use App\Mail\ListCollaboratorInvite;
|
||||||
use App\Models\Invitation;
|
use App\Models\Invitation;
|
||||||
|
use Exception;
|
||||||
|
use Illuminate\Database\Eloquent\ModelNotFoundException;
|
||||||
use Illuminate\Http\Request;
|
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
|
class InvitationController extends Controller
|
||||||
{
|
{
|
||||||
|
private Invitation $invitation;
|
||||||
|
|
||||||
|
public function __construct(Invitation $invitation)
|
||||||
|
{
|
||||||
|
$this->invitation = $invitation;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Display a listing of the resource.
|
* 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);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
$user = Auth::user();
|
||||||
* Display the specified resource.
|
if ($user) {
|
||||||
*/
|
$user->sharedLists()->attach($invitation->movie_list_id, ['role' => 'viewer']);
|
||||||
public function show(Invitation $invitation)
|
$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' => 'Invitations sent!'], 201);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Display the specified resource.
|
||||||
|
*/
|
||||||
|
public function show(Invitation $invitation)
|
||||||
|
{
|
||||||
|
//
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Remove the specified resource from storage.
|
* Remove the specified resource from storage.
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
|
|
@ -4,11 +4,13 @@ namespace App\Http\Controllers;
|
||||||
|
|
||||||
use App\Http\Requests\CreateMovieListRequest;
|
use App\Http\Requests\CreateMovieListRequest;
|
||||||
use App\Http\Requests\UpdateMovieListRequest;
|
use App\Http\Requests\UpdateMovieListRequest;
|
||||||
|
use App\Http\Resources\MovieListResource;
|
||||||
use App\Interfaces\MovieDbInterface;
|
use App\Interfaces\MovieDbInterface;
|
||||||
use App\Models\Movie;
|
use App\Models\Movie;
|
||||||
use App\Models\MovieList;
|
use App\Models\MovieList;
|
||||||
use Illuminate\Database\Eloquent\ModelNotFoundException;
|
use Illuminate\Http\JsonResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\Auth;
|
||||||
use Illuminate\Support\Str;
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
class MovieListController extends Controller
|
class MovieListController extends Controller
|
||||||
|
|
@ -16,9 +18,15 @@ class MovieListController extends Controller
|
||||||
/**
|
/**
|
||||||
* Display a listing of the resource.
|
* Display a listing of the resource.
|
||||||
*/
|
*/
|
||||||
public function index()
|
public function index(): JsonResponse
|
||||||
{
|
{
|
||||||
return MovieList::all();
|
$user = Auth::user();
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'movie_lists' => $user->movieLists,
|
||||||
|
'shared_lists' => $user->sharedLists,
|
||||||
|
], 200);
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -41,37 +49,36 @@ class MovieListController extends Controller
|
||||||
/**
|
/**
|
||||||
* Display the specified resource.
|
* Display the specified resource.
|
||||||
*/
|
*/
|
||||||
public function show(MovieList $movieList)
|
public function show(MovieList $movieList): MovieListResource
|
||||||
{
|
{
|
||||||
$this->authorize('view', $movieList);
|
$this->authorize('view', $movieList);
|
||||||
try {
|
|
||||||
return $movieList->load('movies');
|
return MovieListResource::make($movieList->load('movies', 'collaborators'));
|
||||||
} catch (ModelNotFoundException $e) {
|
|
||||||
return response()->json(['message' => 'Movie list not found'], 404);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Update the specified resource in storage.
|
* Update the specified resource in storage.
|
||||||
*/
|
*/
|
||||||
public function update(UpdateMovieListRequest $request, MovieList $movieList)
|
public function update(UpdateMovieListRequest $request, MovieList $movieList): MovieListResource
|
||||||
{
|
{
|
||||||
$validated = $request->validated();
|
$validated = $request->validated();
|
||||||
$movieList->update($validated);
|
$movieList->update($validated);
|
||||||
|
|
||||||
return response()->json($movieList, 200);
|
return MovieListResource::make($movieList);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Remove the specified resource from storage.
|
* Remove the specified resource from storage.
|
||||||
*/
|
*/
|
||||||
public function destroy(MovieList $movieList)
|
public function destroy(MovieList $movieList): JsonResponse
|
||||||
{
|
{
|
||||||
$this->authorize('delete', $movieList);
|
$this->authorize('delete', $movieList);
|
||||||
$movieList->delete();
|
$movieList->delete();
|
||||||
|
|
||||||
|
return response()->json(['message', 'Movie list deleted successfully'], 204);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function addMovie(MovieDbInterface $movieDb, Request $request, MovieList $movieList)
|
public function addMovie(MovieDbInterface $movieDb, Request $request, MovieList $movieList): MovieListResource
|
||||||
{
|
{
|
||||||
$this->authorize('update', $movieList);
|
$this->authorize('update', $movieList);
|
||||||
$movieResult = $movieDb->find($request->input('movie')['imdbId'], ['type' => 'imdb']);
|
$movieResult = $movieDb->find($request->input('movie')['imdbId'], ['type' => 'imdb']);
|
||||||
|
|
@ -80,16 +87,16 @@ class MovieListController extends Controller
|
||||||
$movieList->movies()->attach($movie);
|
$movieList->movies()->attach($movie);
|
||||||
$movieList->load('movies');
|
$movieList->load('movies');
|
||||||
|
|
||||||
return response()->json($movieList);
|
return MovieListResource::make($movieList);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function removeMovie(MovieDbInterface $movieDb, Request $request, MovieList $movieList, Movie $movie)
|
public function removeMovie(Request $request, MovieList $movieList, Movie $movie): MovieListResource
|
||||||
{
|
{
|
||||||
$this->authorize('update', $movieList);
|
$this->authorize('update', $movieList);
|
||||||
|
|
||||||
$movieList->movies()->detach($movie);
|
$movieList->movies()->detach($movie);
|
||||||
$movieList->load('movies');
|
$movieList->load('movies');
|
||||||
|
|
||||||
return response()->json($movieList);
|
return MovieListResource::make($movieList);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
55
app/Http/Requests/CreateInvitationRequest.php
Normal file
55
app/Http/Requests/CreateInvitationRequest.php
Normal 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.',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
31
app/Http/Requests/PasswordResetRequest.php
Normal file
31
app/Http/Requests/PasswordResetRequest.php
Normal 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',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -24,7 +24,6 @@ class RegisterRequest extends FormRequest
|
||||||
return [
|
return [
|
||||||
'username' => 'required|string|max:255|unique:users',
|
'username' => 'required|string|max:255|unique:users',
|
||||||
'email' => 'required|string|email|max:255|unique:users',
|
'email' => 'required|string|email|max:255|unique:users',
|
||||||
'password' => 'required|string|min:8|confirmed',
|
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
42
app/Http/Resources/MovieListResource.php
Normal file
42
app/Http/Resources/MovieListResource.php
Normal file
|
|
@ -0,0 +1,42 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Resources;
|
||||||
|
|
||||||
|
use Auth;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Http\Resources\Json\JsonResource;
|
||||||
|
|
||||||
|
class MovieListResource extends JsonResource
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Transform the resource into an array.
|
||||||
|
*
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public function toArray(Request $request): array
|
||||||
|
{
|
||||||
|
$user_id = Auth::id();
|
||||||
|
|
||||||
|
return [
|
||||||
|
'id' => $this->id,
|
||||||
|
'name' => $this->name,
|
||||||
|
'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) => [
|
||||||
|
'username' => $user->username,
|
||||||
|
'role' => $user->pivot->role,
|
||||||
|
])),
|
||||||
|
'movies' => $this->whenLoaded('movies'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function getRole(int $owner_id, int $user_id): ?string
|
||||||
|
{
|
||||||
|
if ($owner_id === $user_id) {
|
||||||
|
return 'owner';
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->getUserRole($user_id);
|
||||||
|
}
|
||||||
|
}
|
||||||
65
app/Mail/ListCollaboratorInvite.php
Normal file
65
app/Mail/ListCollaboratorInvite.php
Normal 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 [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -6,5 +6,13 @@ use Illuminate\Database\Eloquent\Model;
|
||||||
|
|
||||||
class Invitation extends Model
|
class Invitation extends Model
|
||||||
{
|
{
|
||||||
//
|
const EXPIRATION_DAYS = 7;
|
||||||
|
|
||||||
|
protected $fillable = [
|
||||||
|
'email',
|
||||||
|
'token',
|
||||||
|
'movie_list_id',
|
||||||
|
'status',
|
||||||
|
'expires_at',
|
||||||
|
];
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@
|
||||||
namespace App\Models;
|
namespace App\Models;
|
||||||
|
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
||||||
|
|
||||||
class MovieList extends Model
|
class MovieList extends Model
|
||||||
|
|
@ -14,8 +15,30 @@ class MovieList extends Model
|
||||||
'slug',
|
'slug',
|
||||||
];
|
];
|
||||||
|
|
||||||
|
protected $with = ['listOwner'];
|
||||||
|
|
||||||
|
public function listOwner(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(User::class, 'owner');
|
||||||
|
}
|
||||||
|
|
||||||
public function movies(): BelongsToMany
|
public function movies(): BelongsToMany
|
||||||
{
|
{
|
||||||
return $this->belongsToMany(Movie::class);
|
return $this->belongsToMany(Movie::class);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function getUserRole($userId)
|
||||||
|
{
|
||||||
|
return $this->collaborators()
|
||||||
|
->where('user_id', $userId)
|
||||||
|
->first()
|
||||||
|
?->pivot
|
||||||
|
->role;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function collaborators(): BelongsToMany
|
||||||
|
{
|
||||||
|
return $this->belongsToMany(User::class, 'movie_list_user')
|
||||||
|
->withPivot('role');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,8 @@ namespace App\Models;
|
||||||
|
|
||||||
// use Illuminate\Contracts\Auth\MustVerifyEmail;
|
// use Illuminate\Contracts\Auth\MustVerifyEmail;
|
||||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
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\Database\Eloquent\Relations\HasOne;
|
||||||
use Illuminate\Foundation\Auth\User as Authenticatable;
|
use Illuminate\Foundation\Auth\User as Authenticatable;
|
||||||
use Illuminate\Notifications\Notifiable;
|
use Illuminate\Notifications\Notifiable;
|
||||||
|
|
@ -21,7 +23,6 @@ class User extends Authenticatable
|
||||||
protected $fillable = [
|
protected $fillable = [
|
||||||
'username',
|
'username',
|
||||||
'email',
|
'email',
|
||||||
'password',
|
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -34,6 +35,21 @@ class User extends Authenticatable
|
||||||
'remember_token',
|
'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.
|
* Get the attributes that should be cast.
|
||||||
*
|
*
|
||||||
|
|
@ -46,9 +62,4 @@ class User extends Authenticatable
|
||||||
'password' => 'hashed',
|
'password' => 'hashed',
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
public function profile(): HasOne
|
|
||||||
{
|
|
||||||
return $this->hasOne(Profile::class);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -22,7 +22,7 @@ class MovieListPolicy
|
||||||
|
|
||||||
public function view(User $user, MovieList $movieList): bool
|
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;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -31,6 +31,7 @@ class MovieListPolicy
|
||||||
|
|
||||||
public function update(User $user, MovieList $movieList): bool
|
public function update(User $user, MovieList $movieList): bool
|
||||||
{
|
{
|
||||||
|
|
||||||
if ($movieList->owner === $user->getKey()) {
|
if ($movieList->owner === $user->getKey()) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,9 @@
|
||||||
namespace App\Providers;
|
namespace App\Providers;
|
||||||
|
|
||||||
use App\Interfaces\MovieDbInterface;
|
use App\Interfaces\MovieDbInterface;
|
||||||
|
use App\Models\User;
|
||||||
use App\Services\OmdbMovieService;
|
use App\Services\OmdbMovieService;
|
||||||
|
use Illuminate\Auth\Notifications\ResetPassword;
|
||||||
use Illuminate\Support\ServiceProvider;
|
use Illuminate\Support\ServiceProvider;
|
||||||
|
|
||||||
class AppServiceProvider extends ServiceProvider
|
class AppServiceProvider extends ServiceProvider
|
||||||
|
|
@ -21,6 +23,8 @@ class AppServiceProvider extends ServiceProvider
|
||||||
*/
|
*/
|
||||||
public function boot(): void
|
public function boot(): void
|
||||||
{
|
{
|
||||||
//
|
ResetPassword::createUrlUsing(function (User $user, string $token) {
|
||||||
|
return config('app.frontend_url')."/auth/reset-password/$token?email=".urlencode($user->email);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -82,7 +82,7 @@ class OmdbMovieService implements MovieDbInterface
|
||||||
'plot' => $movieDetails->plot,
|
'plot' => $movieDetails->plot,
|
||||||
'genre' => $movieDetails->genre,
|
'genre' => $movieDetails->genre,
|
||||||
'mpaa_rating' => $movieDetails->mpaaRating,
|
'mpaa_rating' => $movieDetails->mpaaRating,
|
||||||
'critic_scores' => json_encode($movieDetails->criticScores),
|
'critic_scores' => $movieDetails->criticScores,
|
||||||
'poster' => $movieDetails->poster,
|
'poster' => $movieDetails->poster,
|
||||||
'added_by' => auth()->id(),
|
'added_by' => auth()->id(),
|
||||||
]);
|
]);
|
||||||
|
|
@ -169,7 +169,7 @@ class OmdbMovieService implements MovieDbInterface
|
||||||
'plot' => $movieDetails->plot,
|
'plot' => $movieDetails->plot,
|
||||||
'genre' => $movieDetails->genre,
|
'genre' => $movieDetails->genre,
|
||||||
'mpaa_rating' => $movieDetails->mpaaRating,
|
'mpaa_rating' => $movieDetails->mpaaRating,
|
||||||
'critic_scores' => json_encode($movieDetails->criticScores),
|
'critic_scores' => $movieDetails->criticScores,
|
||||||
'poster' => $movieDetails->poster,
|
'poster' => $movieDetails->poster,
|
||||||
'added_by' => auth()->id(),
|
'added_by' => auth()->id(),
|
||||||
]);
|
]);
|
||||||
|
|
|
||||||
|
|
@ -53,6 +53,7 @@ return [
|
||||||
*/
|
*/
|
||||||
|
|
||||||
'url' => env('APP_URL', 'http://localhost'),
|
'url' => env('APP_URL', 'http://localhost'),
|
||||||
|
'frontend_url' => env('FRONTEND_URL', 'http://localhost:3000'),
|
||||||
|
|
||||||
/*
|
/*
|
||||||
|--------------------------------------------------------------------------
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
|
@ -36,7 +36,9 @@ return [
|
||||||
*/
|
*/
|
||||||
|
|
||||||
'mailers' => [
|
'mailers' => [
|
||||||
|
'mailgun' => [
|
||||||
|
'transport' => 'mailgun',
|
||||||
|
],
|
||||||
'smtp' => [
|
'smtp' => [
|
||||||
'transport' => 'smtp',
|
'transport' => 'smtp',
|
||||||
'scheme' => env('MAIL_SCHEME'),
|
'scheme' => env('MAIL_SCHEME'),
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,7 @@ return new class extends Migration
|
||||||
$table->string('username')->unique();
|
$table->string('username')->unique();
|
||||||
$table->string('email')->unique();
|
$table->string('email')->unique();
|
||||||
$table->timestamp('email_verified_at')->nullable();
|
$table->timestamp('email_verified_at')->nullable();
|
||||||
$table->string('password');
|
$table->string('password')->nullable();
|
||||||
$table->rememberToken();
|
$table->rememberToken();
|
||||||
$table->timestamps();
|
$table->timestamps();
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@ return new class extends Migration
|
||||||
$table->string('email');
|
$table->string('email');
|
||||||
$table->string('token')->unique();
|
$table->string('token')->unique();
|
||||||
$table->foreignId('movie_list_id')->constrained()->cascadeOnDelete();
|
$table->foreignId('movie_list_id')->constrained()->cascadeOnDelete();
|
||||||
|
$table->enum('status', ['pending', 'accepted_login_pending', 'accepted', 'declined'])->default('pending');
|
||||||
$table->dateTime('expires_at');
|
$table->dateTime('expires_at');
|
||||||
$table->softDeletes();
|
$table->softDeletes();
|
||||||
$table->timestamps();
|
$table->timestamps();
|
||||||
|
|
|
||||||
|
|
@ -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');
|
||||||
|
}
|
||||||
|
};
|
||||||
7
resources/views/email/list-collaborator-invite.blade.php
Normal file
7
resources/views/email/list-collaborator-invite.blade.php
Normal 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>
|
||||||
|
|
@ -1,26 +1,32 @@
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
use App\Http\Controllers\AuthController;
|
use App\Http\Controllers\AuthController;
|
||||||
|
use App\Http\Controllers\InvitationController;
|
||||||
use App\Http\Controllers\MovieController;
|
use App\Http\Controllers\MovieController;
|
||||||
use App\Http\Controllers\MovieListController;
|
use App\Http\Controllers\MovieListController;
|
||||||
use Illuminate\Http\Request;
|
|
||||||
use Illuminate\Support\Facades\Route;
|
use Illuminate\Support\Facades\Route;
|
||||||
|
|
||||||
// Public auth routes
|
// Public auth routes
|
||||||
Route::post('/register', [AuthController::class, 'register']);
|
Route::post('/register', [AuthController::class, 'register'])->name('auth.register');
|
||||||
Route::post('/login', [AuthController::class, 'login']);
|
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
|
// Authenticated routes
|
||||||
Route::middleware('auth:sanctum')->group(function () {
|
Route::middleware('auth:sanctum')->group(function () {
|
||||||
Route::post('/logout', [AuthController::class, 'logout']);
|
Route::post('/logout', [AuthController::class, 'logout'])->name('auth.logout');
|
||||||
Route::get('/user', fn (Request $request) => $request->user());
|
|
||||||
|
// Invitations
|
||||||
|
Route::post('/invitations', [InvitationController::class, 'store'])->name('invitations.store');
|
||||||
|
|
||||||
// Movies
|
// Movies
|
||||||
Route::get('/movies/search/{query}', [MovieController::class, 'search'])->name('movies.search');
|
Route::get('/movies/search/{query}', [MovieController::class, 'search'])->name('movies.search');
|
||||||
|
|
||||||
// Movie Lists
|
// Movie Lists
|
||||||
Route::get('/movielists', [MovieListController::class, 'index'])->name('movielists.index');
|
Route::get('/movielists', [MovieListController::class, 'index'])->name('movielists.index');
|
||||||
Route::put('/movielists/', [MovieListController::class, 'index'])->name('movielists.index');
|
Route::put('/movielists/{movieList}', [MovieListController::class, 'update'])->name('movielists.update');
|
||||||
Route::get('/movielists/{movieList}', [MovieListController::class, 'show'])->name('movielists.show');
|
Route::get('/movielists/{movieList}', [MovieListController::class, 'show'])->name('movielists.show');
|
||||||
Route::post('/movielists', [MovieListController::class, 'store'])->name('movielists.store');
|
Route::post('/movielists', [MovieListController::class, 'store'])->name('movielists.store');
|
||||||
Route::post('/movielists/{movieList}/movies', [MovieListController::class, 'addMovie'])->name('movielists.addMovie');
|
Route::post('/movielists/{movieList}/movies', [MovieListController::class, 'addMovie'])->name('movielists.addMovie');
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue