mirror of
https://github.com/Poniverse/Pony.fm.git
synced 2024-11-24 05:57:59 +01:00
#25: Implemented UI for managing email subscriptions.
This commit is contained in:
parent
45793a2988
commit
b401a0ae7e
12 changed files with 216 additions and 74 deletions
|
@ -20,6 +20,7 @@
|
||||||
|
|
||||||
namespace Poniverse\Ponyfm\Commands;
|
namespace Poniverse\Ponyfm\Commands;
|
||||||
|
|
||||||
|
use DB;
|
||||||
use Poniverse\Ponyfm\Models\Image;
|
use Poniverse\Ponyfm\Models\Image;
|
||||||
use Poniverse\Ponyfm\Models\User;
|
use Poniverse\Ponyfm\Models\User;
|
||||||
use Gate;
|
use Gate;
|
||||||
|
@ -53,6 +54,8 @@ class SaveAccountSettingsCommand extends CommandBase
|
||||||
*/
|
*/
|
||||||
public function execute()
|
public function execute()
|
||||||
{
|
{
|
||||||
|
$this->_input['notifications'] = json_decode($this->_input['notifications'], true);
|
||||||
|
|
||||||
$rules = [
|
$rules = [
|
||||||
'display_name' => 'required|min:3|max:26',
|
'display_name' => 'required|min:3|max:26',
|
||||||
'bio' => 'textarea_length:250',
|
'bio' => 'textarea_length:250',
|
||||||
|
@ -62,7 +65,9 @@ class SaveAccountSettingsCommand extends CommandBase
|
||||||
'min:'.config('ponyfm.user_slug_minimum_length'),
|
'min:'.config('ponyfm.user_slug_minimum_length'),
|
||||||
'regex:/^[a-z\d-]+$/',
|
'regex:/^[a-z\d-]+$/',
|
||||||
'is_not_reserved_slug'
|
'is_not_reserved_slug'
|
||||||
]
|
],
|
||||||
|
'notifications.*.activity_type' => 'required|exists:activity_types,activity_type',
|
||||||
|
'notifications.*.receive_emails' => 'present|boolean',
|
||||||
];
|
];
|
||||||
|
|
||||||
if ($this->_input['uses_gravatar'] == 'true') {
|
if ($this->_input['uses_gravatar'] == 'true') {
|
||||||
|
@ -80,6 +85,7 @@ class SaveAccountSettingsCommand extends CommandBase
|
||||||
return CommandResponse::fail($validator);
|
return CommandResponse::fail($validator);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
$this->_user->bio = $this->_input['bio'];
|
$this->_user->bio = $this->_input['bio'];
|
||||||
$this->_user->display_name = $this->_input['display_name'];
|
$this->_user->display_name = $this->_input['display_name'];
|
||||||
$this->_user->slug = $this->_input['slug'];
|
$this->_user->slug = $this->_input['slug'];
|
||||||
|
@ -101,8 +107,28 @@ class SaveAccountSettingsCommand extends CommandBase
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
DB::transaction(function() {
|
||||||
$this->_user->save();
|
$this->_user->save();
|
||||||
|
|
||||||
|
// Sync email subscriptions
|
||||||
|
$emailSubscriptions = $this->_user->emailSubscriptions->keyBy('activity_type');
|
||||||
|
foreach ($this->_input['notifications'] as $notificationSetting) {
|
||||||
|
|
||||||
|
if (
|
||||||
|
$notificationSetting['receive_emails'] &&
|
||||||
|
!$emailSubscriptions->offsetExists($notificationSetting['activity_type'])
|
||||||
|
) {
|
||||||
|
$this->_user->emailSubscriptions()->create(['activity_type' => $notificationSetting['activity_type']]);
|
||||||
|
|
||||||
|
} elseif (
|
||||||
|
!$notificationSetting['receive_emails'] &&
|
||||||
|
$emailSubscriptions->offsetExists($notificationSetting['activity_type'])
|
||||||
|
) {
|
||||||
|
$emailSubscriptions->get($notificationSetting['activity_type'])->delete();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
return CommandResponse::succeed();
|
return CommandResponse::succeed();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -20,13 +20,12 @@
|
||||||
|
|
||||||
namespace Poniverse\Ponyfm\Http\Controllers\Api\Web;
|
namespace Poniverse\Ponyfm\Http\Controllers\Api\Web;
|
||||||
|
|
||||||
use Carbon\Carbon;
|
|
||||||
use Poniverse\Ponyfm\Http\Controllers\ApiControllerBase;
|
use Poniverse\Ponyfm\Http\Controllers\ApiControllerBase;
|
||||||
use Poniverse\Ponyfm\Commands\SaveAccountSettingsCommand;
|
use Poniverse\Ponyfm\Commands\SaveAccountSettingsCommand;
|
||||||
use Poniverse\Ponyfm\Models\User;
|
use Poniverse\Ponyfm\Models\User;
|
||||||
use Gate;
|
use Gate;
|
||||||
use Auth;
|
use Auth;
|
||||||
use Illuminate\Support\Facades\Request;
|
use Request;
|
||||||
use Response;
|
use Response;
|
||||||
|
|
||||||
class AccountController extends ApiControllerBase
|
class AccountController extends ApiControllerBase
|
||||||
|
@ -71,7 +70,8 @@ class AccountController extends ApiControllerBase
|
||||||
'username' => $user->username,
|
'username' => $user->username,
|
||||||
'gravatar' => $user->gravatar ? $user->gravatar : $user->email,
|
'gravatar' => $user->gravatar ? $user->gravatar : $user->email,
|
||||||
'avatar_url' => !$user->uses_gravatar ? $user->getAvatarUrl() : null,
|
'avatar_url' => !$user->uses_gravatar ? $user->getAvatarUrl() : null,
|
||||||
'uses_gravatar' => $user->uses_gravatar == 1
|
'uses_gravatar' => $user->uses_gravatar == 1,
|
||||||
|
'notifications' => $user->getNotificationSettings()
|
||||||
], 200);
|
], 200);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -25,13 +25,13 @@ use DB;
|
||||||
use Poniverse\Ponyfm\Models\Email;
|
use Poniverse\Ponyfm\Models\Email;
|
||||||
use Poniverse\Ponyfm\Models\EmailSubscription;
|
use Poniverse\Ponyfm\Models\EmailSubscription;
|
||||||
|
|
||||||
// TODO: #25 - finish these endpoints and secure them properly
|
|
||||||
|
|
||||||
class NotificationsController extends Controller {
|
class NotificationsController extends Controller {
|
||||||
|
/**
|
||||||
|
* @param $emailKey
|
||||||
|
* @return \Illuminate\Http\RedirectResponse|\Illuminate\Routing\Redirector
|
||||||
|
*/
|
||||||
public function getEmailClick($emailKey) {
|
public function getEmailClick($emailKey) {
|
||||||
App::abort(403, "This isn't implemented yet!");
|
|
||||||
|
|
||||||
$emailKey = decrypt($emailKey);
|
|
||||||
/** @var Email $email */
|
/** @var Email $email */
|
||||||
$email = Email::findOrFail($emailKey);
|
$email = Email::findOrFail($emailKey);
|
||||||
|
|
||||||
|
@ -45,11 +45,9 @@ class NotificationsController extends Controller {
|
||||||
}
|
}
|
||||||
|
|
||||||
public function getEmailUnsubscribe($subscriptionKey) {
|
public function getEmailUnsubscribe($subscriptionKey) {
|
||||||
App::abort(403, "This isn't implemented yet!");
|
$subscription = EmailSubscription::findOrFail($subscriptionKey);
|
||||||
|
$subscription->delete();
|
||||||
|
|
||||||
$subscriptionId = decrypt($subscriptionKey);
|
return 'Unsubscribed!';
|
||||||
$subscription = EmailSubscription::findOrFail($subscriptionId);
|
|
||||||
|
|
||||||
return var_export($subscription);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -96,8 +96,7 @@ abstract class BaseNotification extends Mailable {
|
||||||
* @return string
|
* @return string
|
||||||
*/
|
*/
|
||||||
protected function generateUnsubscribeUrl() {
|
protected function generateUnsubscribeUrl() {
|
||||||
$subscriptionKey = encrypt($this->emailRecord->getSubscription()->id);
|
return route('email:unsubscribe', ['subscriptionKey' => $this->emailRecord->getSubscription()->id]);
|
||||||
return route('email:unsubscribe', ['subscriptionKey' => $subscriptionKey]);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -106,8 +105,7 @@ abstract class BaseNotification extends Mailable {
|
||||||
* @return string
|
* @return string
|
||||||
*/
|
*/
|
||||||
protected function generateNotificationUrl() {
|
protected function generateNotificationUrl() {
|
||||||
$emailKey = encrypt($this->emailRecord->id);
|
return route('email:click', ['emailKey' => $this->emailRecord->id]);
|
||||||
return route('email:click', ['emailKey' => $emailKey]);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
@ -42,6 +42,8 @@ use Poniverse\Ponyfm\Models\Notification;
|
||||||
class Email extends Model
|
class Email extends Model
|
||||||
{
|
{
|
||||||
use UuidModelTrait;
|
use UuidModelTrait;
|
||||||
|
// Non-sequential UUID's are desirable for this model.
|
||||||
|
protected $uuidVersion = 4;
|
||||||
|
|
||||||
public function notification() {
|
public function notification() {
|
||||||
return $this->belongsTo(Notification::class, 'notification_id', 'id', 'notifications');
|
return $this->belongsTo(Notification::class, 'notification_id', 'id', 'notifications');
|
||||||
|
|
|
@ -46,6 +46,10 @@ use Poniverse\Ponyfm\Models\User;
|
||||||
class EmailSubscription extends Model
|
class EmailSubscription extends Model
|
||||||
{
|
{
|
||||||
use UuidModelTrait, SoftDeletes;
|
use UuidModelTrait, SoftDeletes;
|
||||||
|
// Non-sequential UUID's are desirable for this model.
|
||||||
|
protected $uuidVersion = 4;
|
||||||
|
|
||||||
|
protected $fillable = ['activity_type'];
|
||||||
|
|
||||||
public function user() {
|
public function user() {
|
||||||
return $this->belongsTo(User::class, 'user_id', 'id', 'users');
|
return $this->belongsTo(User::class, 'user_id', 'id', 'users');
|
||||||
|
|
|
@ -420,6 +420,38 @@ class User extends Model implements AuthenticatableContract, CanResetPasswordCon
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper method that returns a row for every type of notifiable activity.
|
||||||
|
* It's meant to be used for the notification settings screen.
|
||||||
|
*
|
||||||
|
* @return array
|
||||||
|
*/
|
||||||
|
private function emailSubscriptionsJoined() {
|
||||||
|
return DB::select('
|
||||||
|
SELECT "subscriptions".*, "activity_types".* FROM
|
||||||
|
(SELECT * FROM "email_subscriptions"
|
||||||
|
WHERE "email_subscriptions"."deleted_at" IS NULL
|
||||||
|
AND "email_subscriptions"."user_id" = ?) as "subscriptions"
|
||||||
|
RIGHT JOIN "activity_types"
|
||||||
|
ON "subscriptions"."activity_type" = "activity_types"."activity_type"
|
||||||
|
', [$this->id]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getNotificationSettings() {
|
||||||
|
$settings = [];
|
||||||
|
$emailSubscriptions = $this->emailSubscriptionsJoined();
|
||||||
|
|
||||||
|
foreach($emailSubscriptions as $subscription) {
|
||||||
|
$settings[] = [
|
||||||
|
'description' => $subscription->description,
|
||||||
|
'activity_type' => $subscription->activity_type,
|
||||||
|
'receive_emails' => $subscription->id !== NULL
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $settings;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns this model in Elasticsearch-friendly form. The array returned by
|
* Returns this model in Elasticsearch-friendly form. The array returned by
|
||||||
* this method should match the current mapping for this model's ES type.
|
* this method should match the current mapping for this model's ES type.
|
||||||
|
|
|
@ -0,0 +1,58 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pony.fm - A community for pony fan music.
|
||||||
|
* Copyright (C) 2016 Peter Deltchev
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU Affero General Public License as published by
|
||||||
|
* the Free Software Foundation, either version 3 of the License, or
|
||||||
|
* (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU Affero General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU Affero General Public License
|
||||||
|
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
|
||||||
|
class UpdateActivityDescriptions extends Migration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Run the migrations.
|
||||||
|
*
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
public function up()
|
||||||
|
{
|
||||||
|
DB::table('activity_types')->where('activity_type', 1)->update(['description' => 'Updates from the Pony.fm team']);
|
||||||
|
DB::table('activity_types')->where('activity_type', 2)->update(['description' => 'Someone you follow publishes a track']);
|
||||||
|
DB::table('activity_types')->where('activity_type', 3)->update(['description' => 'Someone you follow publishes an album']);
|
||||||
|
DB::table('activity_types')->where('activity_type', 4)->update(['description' => 'Someone you follow creates a playlist']);
|
||||||
|
DB::table('activity_types')->where('activity_type', 5)->update(['description' => 'You get a new follower']);
|
||||||
|
DB::table('activity_types')->where('activity_type', 6)->update(['description' => 'Someone leaves you a comment']);
|
||||||
|
DB::table('activity_types')->where('activity_type', 7)->update(['description' => 'Something of yours is favourited']);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverse the migrations.
|
||||||
|
*
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
public function down()
|
||||||
|
{
|
||||||
|
DB::table('activity_types')->where('activity_type', 1)->update(['description' => 'news']);
|
||||||
|
DB::table('activity_types')->where('activity_type', 2)->update(['description' => 'followee published a track']);
|
||||||
|
DB::table('activity_types')->where('activity_type', 3)->update(['description' => 'followee published an album']);
|
||||||
|
DB::table('activity_types')->where('activity_type', 4)->update(['description' => 'followee published a playlist']);
|
||||||
|
DB::table('activity_types')->where('activity_type', 5)->update(['description' => 'new follower']);
|
||||||
|
DB::table('activity_types')->where('activity_type', 6)->update(['description' => 'new comment']);
|
||||||
|
DB::table('activity_types')->where('activity_type', 7)->update(['description' => 'new favourite']);
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,9 +1,8 @@
|
||||||
Developing notifications for Pony.fm
|
Developing notifications for Pony.fm
|
||||||
====================================
|
====================================
|
||||||
|
|
||||||
Pony.fm's notification system is designed around "drivers" for various
|
Pony.fm's notification system is designed to support various notification
|
||||||
notification delivery methods. The types of notification one can receive
|
delivery methods. The types of notification one can receive are defined in the
|
||||||
are defined in the
|
|
||||||
[`NotificationHandler`](app/Contracts/NotificationHandler.php)
|
[`NotificationHandler`](app/Contracts/NotificationHandler.php)
|
||||||
interface, which is implemented by every class that needs to know about
|
interface, which is implemented by every class that needs to know about
|
||||||
the various notification types.
|
the various notification types.
|
||||||
|
@ -60,20 +59,15 @@ Adding new notification types
|
||||||
[`Activity`](../app/Models/Activity.php) model.
|
[`Activity`](../app/Models/Activity.php) model.
|
||||||
|
|
||||||
|
|
||||||
Adding new notification drivers
|
Adding new notification delivery methods
|
||||||
-------------------------------
|
----------------------------------------
|
||||||
|
|
||||||
1. Create a new class for the driver that implements the
|
1. Implement a method for sending notifications via the new delivery method in
|
||||||
[`NotificationHandler`](../app/Contracts/NotificationHandler.php)
|
the [`PonyfmDriver`](../app/Library/Notifications/PonyfmDriver.php) class.
|
||||||
interface.
|
Use how email delivery is implemented as a guide.
|
||||||
|
|
||||||
2. Make each method from the above interface send the corresponding type
|
2. Add UI for subscribing and unsubscribing to the delivery method to the
|
||||||
of notification to everyone who is to receive it via that driver.
|
[`account settings area`](../public/templates/account/settings.html).
|
||||||
Implement UI and API integrations as needed.
|
|
||||||
|
|
||||||
3. Modify the
|
|
||||||
[`RecipientFinder`](../app/Library/Notifications/RecipientFinder.php)
|
|
||||||
class to build recipient lists for the new driver.
|
|
||||||
|
|
||||||
|
|
||||||
Architectural notes
|
Architectural notes
|
||||||
|
@ -86,14 +80,19 @@ notifications asynchronously.
|
||||||
To that end, the
|
To that end, the
|
||||||
[`NotificationManager`](../app/Library/Notifications/NotificationManager.php)
|
[`NotificationManager`](../app/Library/Notifications/NotificationManager.php)
|
||||||
class is a thin wrapper around the `SendNotifications` job. The job
|
class is a thin wrapper around the `SendNotifications` job. The job
|
||||||
calls the notification drivers asynchronously to actually send the
|
calls the notification logic asynchronously to actually send notifications. This
|
||||||
notifications. This job should run on a dedicated queue in production.
|
job should run on a dedicated queue in production.
|
||||||
|
|
||||||
The [`NotificationHandler`](../app/Contracts/NotificationHandler.php)
|
The [`NotificationHandler`](../app/Contracts/NotificationHandler.php)
|
||||||
interface is key to maintaining type safety - it ensures that drivers
|
interface is key to maintaining type safety - it ensures that many classes
|
||||||
and `NotificationManager` all support every type of notification. All
|
associated with notifications all support every type of notification. Classes
|
||||||
classes that have logic specific to a notification type implement this
|
that have logic specific to a notification type implement this interface to
|
||||||
interface to ensure that all notification types are handled.
|
ensure that all notification types are handled.
|
||||||
|
|
||||||
|
Furthermore, the `activity_types` table is used to provide referential data
|
||||||
|
integrity in the database - all notifications are linked to an activity record,
|
||||||
|
and each activity record must correspond to a valid activity type. This table is
|
||||||
|
also used for validation of users' subscription preferences.
|
||||||
|
|
||||||
There's one exception to the use of `NotificationHandler` - the
|
There's one exception to the use of `NotificationHandler` - the
|
||||||
[`Activity`](../app/Models/Activity.php) model. The logic for mapping the
|
[`Activity`](../app/Models/Activity.php) model. The logic for mapping the
|
||||||
|
@ -112,7 +111,7 @@ interface here would have made this logic a lot more obtuse.
|
||||||
3. An `Activity` record is created for the action.
|
3. An `Activity` record is created for the action.
|
||||||
|
|
||||||
4. A `Notification` record is created for every user who is to receive a
|
4. A `Notification` record is created for every user who is to receive a
|
||||||
notification about that activity. These records act as Pony.fm's on-site
|
notification about that activity. These records double as Pony.fm's on-site
|
||||||
notifications and cannot be disabled.
|
notifications and cannot be disabled.
|
||||||
|
|
||||||
5. Depending on subscription preferences, push and email notifications will be
|
5. Depending on subscription preferences, push and email notifications will be
|
||||||
|
|
|
@ -7,7 +7,8 @@
|
||||||
</button>
|
</button>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
<div class="stretch-to-bottom">
|
<div class="stretch-to-bottom row">
|
||||||
|
<div class="col-md-4">
|
||||||
<div class="form-row" ng-class="{'has-error': errors.display_name != null}">
|
<div class="form-row" ng-class="{'has-error': errors.display_name != null}">
|
||||||
<label class="strong" for="display_name">Display Name</label>
|
<label class="strong" for="display_name">Display Name</label>
|
||||||
<input type="text" ng-disabled="isSaving" ng-change="touchModel()" placeholder="Display Name" id="display_name" ng-model="settings.display_name" />
|
<input type="text" ng-disabled="isSaving" ng-change="touchModel()" placeholder="Display Name" id="display_name" ng-model="settings.display_name" />
|
||||||
|
@ -41,4 +42,24 @@
|
||||||
<div class="error" ng-show="errors.avatar != null">{{errors.avatar}}</div>
|
<div class="error" ng-show="errors.avatar != null">{{errors.avatar}}</div>
|
||||||
<div class="error" ng-show="errors.gravatar != null">{{errors.gravatar}}</div>
|
<div class="error" ng-show="errors.gravatar != null">{{errors.gravatar}}</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-8">
|
||||||
|
<h3>Notification settings</h3>
|
||||||
|
<p>On-site notifications are always on. That way, you can always see
|
||||||
|
what you've missed whenever you log on to Pony.fm!</p>
|
||||||
|
|
||||||
|
<table class="table table-hover">
|
||||||
|
<thead>
|
||||||
|
<th></th>
|
||||||
|
<th>Email me!</th>
|
||||||
|
<th>Give me a push notification!</th>
|
||||||
|
</thead>
|
||||||
|
<tr ng-repeat="notification in ::settings.notifications track by notification.activity_type">
|
||||||
|
<td><label>{{ ::notification.description }}</label></td>
|
||||||
|
<td><input type="checkbox" ng-change="touchModel()" ng-model="notification.receive_emails" /></td>
|
||||||
|
<td>Coming soon!</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|
|
@ -81,6 +81,8 @@ module.exports = angular.module('ponyfm').controller "account-settings", [
|
||||||
return if value == null
|
return if value == null
|
||||||
if typeof(value) == 'object'
|
if typeof(value) == 'object'
|
||||||
formData.append name, value, value.name
|
formData.append name, value, value.name
|
||||||
|
else if name == 'notifications'
|
||||||
|
formData.append name, JSON.stringify(value)
|
||||||
else
|
else
|
||||||
formData.append name, value
|
formData.append name, value
|
||||||
|
|
||||||
|
|
|
@ -78,8 +78,10 @@ Route::get('p{id}/dl.{extension}', 'PlaylistsController@getDownload');
|
||||||
Route::get('notifications', 'AccountController@getNotifications');
|
Route::get('notifications', 'AccountController@getNotifications');
|
||||||
|
|
||||||
|
|
||||||
Route::get('notifications/email/unsubscribe/{subscriptionKey}', 'NotificationsController@getEmailUnsubscribe')->name('email:unsubscribe');
|
Route::group(['prefix' => 'notifications/email'], function() {
|
||||||
Route::get('notifications/email/click/{emailKey}', 'NotificationsController@getEmailClick')->name('email:click');
|
Route::get('/unsubscribe/{subscriptionKey}', 'NotificationsController@getEmailUnsubscribe')->name('email:unsubscribe');
|
||||||
|
Route::get('/click/{emailKey}', 'NotificationsController@getEmailClick')->name('email:click');
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
Route::get('oembed', 'TracksController@getOembed');
|
Route::get('oembed', 'TracksController@getOembed');
|
||||||
|
|
Loading…
Reference in a new issue