Skip to content

Commit

Permalink
Merge pull request #196 from lewislarsen/add_unsubscribe_link_to_emails
Browse files Browse the repository at this point in the history
Feature: item email unsubscribe link
  • Loading branch information
Cannonb4ll authored Jan 19, 2023
2 parents 2dbff71 + c173c91 commit b6c60f9
Show file tree
Hide file tree
Showing 13 changed files with 240 additions and 1 deletion.
5 changes: 5 additions & 0 deletions app/Exceptions/Handler.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

namespace App\Exceptions;

use Illuminate\Routing\Exceptions\InvalidSignatureException;
use Throwable;
use Illuminate\Foundation\Exceptions\Handler as ExceptionHandler;

Expand Down Expand Up @@ -46,5 +47,9 @@ public function register()
$this->reportable(function (Throwable $e) {
//
});

$this->renderable(function (InvalidSignatureException $e) {
return response()->view('errors.link-expired', [], 403);
});
}
}
21 changes: 21 additions & 0 deletions app/Http/Controllers/ItemEmailUnsubscribeController.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
<?php

namespace App\Http\Controllers;

use App\Models\Item;
use App\Models\User;
use Illuminate\Http\RedirectResponse;

class ItemEmailUnsubscribeController extends Controller
{
public function __invoke(Item $item, User $user): RedirectResponse
{
if (!$user->isSubscribedToItem($item)) {
return redirect()->route('home');
}

$user->toggleVoteSubscription($item->id, Item::class);

return redirect()->route('items.show', $item->getAttributeValue('slug'));
}
}
19 changes: 19 additions & 0 deletions app/Models/User.php
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,25 @@ public function needsToVerifyEmail() : bool
!auth()->user()->hasVerifiedEmail();
}

public function isSubscribedToItem(Item $item): bool
{
return $item->subscribedVotes()->where('user_id', $this->id)->exists();
}

public function toggleVoteSubscription(int $id, string $type)
{
$vote = Vote::where('model_id', $id)
->where('model_type', $type)
->where('user_id', $this->id)
->first();

if (!$vote) {
return;
}

$vote->update(['subscribed' => !$vote->subscribed]);
}

public static function booted()
{
static::creating(function (self $user) {
Expand Down
5 changes: 5 additions & 0 deletions app/Notifications/Item/ItemHasNewCommentNotification.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
use Illuminate\Notifications\Notification;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Support\Facades\URL;

class ItemHasNewCommentNotification extends Notification implements ShouldQueue
{
Expand Down Expand Up @@ -36,6 +37,10 @@ public function toMail($notifiable): MailMessage
'comment' => $this->comment,
'user' => $this->user,
'url' => route('items.show', $this->comment->item) . '#comment-' . $this->comment->id,
'unsubscribeUrl' => URL::signedRoute('items.email-unsubscribe', [
'item' => $this->comment->item,
'user' => $this->user,
]),
]);
}
}
5 changes: 5 additions & 0 deletions app/Notifications/Item/ItemUpdatedNotification.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
use Illuminate\Notifications\Notification;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Support\Facades\URL;

class ItemUpdatedNotification extends Notification implements ShouldQueue
{
Expand All @@ -31,6 +32,10 @@ public function toMail(User $notifiable): MailMessage
'user' => $notifiable,
'item' => $this->item,
'activities' => $this->item->activities()->latest()->limit(2)->get(),
'unsubscribeUrl' => URL::signedRoute('items.email-unsubscribe', [
'item' => $this->item,
'user' => $notifiable,
]),
]);
}
}
6 changes: 6 additions & 0 deletions lang/en/errors.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
<?php

return [
'link-expired.title' => 'Link expired',
'link-expired' => 'The link you clicked has expired or otherwise has become invalid. Please try again.',
];
3 changes: 2 additions & 1 deletion lang/en/notifications.php
Original file line number Diff line number Diff line change
Expand Up @@ -26,5 +26,6 @@
'from' => 'From',
'on' => 'on',

'unsubscribe-info' => 'If you do not want to receive notifications like this anymore, you can unsubscribe from your profile.',
'unsubscribe-info' => 'If you do not want to receive notifications like this anymore, you can unsubscribe from your profile or by clicking the link below.',
'unsubscribe-link' => 'Unsubscribe from future emails about this item.',
];
4 changes: 4 additions & 0 deletions resources/views/emails/item/new-comment.blade.php
Original file line number Diff line number Diff line change
Expand Up @@ -18,4 +18,8 @@

{{ trans('notifications.salutation') }}<br>
{{ config('app.name') }}

<a href="{{ $unsubscribeUrl }}">
{{ trans('notifications.unsubscribe-link') }}
</a>
@endcomponent
4 changes: 4 additions & 0 deletions resources/views/emails/item/updated.blade.php
Original file line number Diff line number Diff line change
Expand Up @@ -18,4 +18,8 @@

{{ trans('notifications.salutation') }}<br>
{{ config('app.name') }}

<a href="{{ $unsubscribeUrl }}">
{{ trans('notifications.unsubscribe-link') }}
</a>
@endcomponent
5 changes: 5 additions & 0 deletions resources/views/errors/link-expired.blade.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
@section('title', trans('errors.link-expired.title'))

<x-app>
<p>{{ trans('errors.link-expired') }}</p>
</x-app>
5 changes: 5 additions & 0 deletions routes/web.php
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
<?php

use App\Http\Controllers\ItemEmailUnsubscribeController;
use Illuminate\Support\Facades\Route;
use App\Http\Controllers\MyController;
use App\Http\Controllers\ItemController;
Expand Down Expand Up @@ -43,3 +44,7 @@
Route::get('mention-search', \App\Http\Controllers\MentionSearchController::class)->name('mention-search');
Route::get('user/{username}', \App\Http\Controllers\PublicUserController::class)->name('public-user');
});

Route::get('/unsubscribe/{item}/{user}', [ItemEmailUnsubscribeController::class, '__invoke'])
->name('items.email-unsubscribe')
->middleware('signed');
101 changes: 101 additions & 0 deletions tests/Feature/Controllers/ItemEmailUnsubscribeControllerTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
<?php

use App\Models\Item;
use App\Models\User;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\URL;

it('can unsubscribe a user from an item', function () {

$user = User::factory()->create();
$item = Item::factory()->create();

DB::table('votes')->insert([
'user_id' => $user->id,
'model_id' => $item->id,
'model_type' => Item::class,
'subscribed' => true,
]);

$this->assertTrue($user->isSubscribedToItem($item));

$response = $this->get(URL::signedRoute('items.email-unsubscribe', [
'item' => $item,
'user' => $user,
]));

$this->assertFalse($user->isSubscribedToItem($item));

$this->assertDatabaseHas('votes', [
'user_id' => $user->id,
'model_id' => $item->id,
'model_type' => Item::class,
'subscribed' => false,
]);

$response->assertRedirect(route('items.show', $item->getAttributeValue('slug')));
$this->assertGuest();
});

it('does not resubscribe a user who clicks the link whilst unsubscribed', function () {

$user = User::factory()->create();
$item = Item::factory()->create();

DB::table('votes')->insert([
'user_id' => $user->id,
'model_id' => $item->id,
'model_type' => Item::class,
'subscribed' => false,
]);

$this->assertFalse($user->isSubscribedToItem($item));

$response = $this->get(URL::signedRoute('items.email-unsubscribe', [
'item' => $item,
'user' => $user,
]));

$this->assertFalse($user->isSubscribedToItem($item));
$response->assertRedirect(route('home'));
$this->assertGuest();
});

it('returns a 403 if the signed link has been modified', function () {

$user = User::factory()->create();
$item = Item::factory()->create();

$response = $this->get(URL::signedRoute('items.email-unsubscribe', [
'item' => $item,
'user' => $user,
]) . '&foo=bar');

$response->assertStatus(403);
});

it('returns a 404 if the item does not exist', function () {

$user = User::factory()->create();
$item = Item::factory()->create();

$response = $this->get(URL::signedRoute('items.email-unsubscribe', [
'item' => 999,
'user' => $user,
]));

$response->assertStatus(404);
});

it('returns a 404 if the user does not exist', function () {

$user = User::factory()->create();
$item = Item::factory()->create();

$response = $this->get(URL::signedRoute('items.email-unsubscribe', [
'item' => $item,
'user' => 999,
]));

$response->assertStatus(404);
});
58 changes: 58 additions & 0 deletions tests/Unit/Models/UserTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
use App\Enums\UserRole;
use App\Models\Comment;
use App\Settings\GeneralSettings;
use Illuminate\Support\Facades\DB;

it('can generate an username upon user creation', function () {
$user = createUser();
Expand Down Expand Up @@ -111,3 +112,60 @@

expect($user->fresh())->toBeNull();
});

it('can return true if a user is a subscribed to an item', function () {

$user = createUser();
$item = Item::factory()->create();

DB::table('votes')->insert([
'user_id' => $user->id,
'model_id' => $item->id,
'model_type' => Item::class,
'subscribed' => true,
]);

$this->assertTrue($user->isSubscribedToItem($item));
});

it('can return false if a user is not subscribed to an item', function () {

$user = createUser();
$item = Item::factory()->create();

DB::table('votes')->insert([
'user_id' => $user->id,
'model_id' => $item->id,
'model_type' => Item::class,
'subscribed' => false,
]);

$this->assertFalse($user->isSubscribedToItem($item));
});

it('toggles the subscription state of a vote the user belongs to', function () {

$user = createUser();
$item = Item::factory()->create();

DB::table('votes')->insert([
'user_id' => $user->id,
'model_id' => $item->id,
'model_type' => Item::class,
'subscribed' => false,
]);

$user->toggleVoteSubscription($item->id, Item::class);

$this->assertTrue($user->isSubscribedToItem($item));
});

it('does not toggle the subscription if the user does not have a vote for that item', function () {

$user = createUser();
$item = Item::factory()->create();

$user->toggleVoteSubscription($item->id, Item::class);

$this->assertFalse($user->isSubscribedToItem($item));
});

0 comments on commit b6c60f9

Please sign in to comment.