#25: Implemented UI for managing email subscriptions.

This commit is contained in:
Peter Deltchev 2016-12-24 18:11:42 -08:00
parent 45793a2988
commit b401a0ae7e
12 changed files with 216 additions and 74 deletions

View file

@ -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,7 +107,27 @@ class SaveAccountSettingsCommand extends CommandBase
} }
} }
$this->_user->save(); DB::transaction(function() {
$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();
} }

View file

@ -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);
} }

View file

@ -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);
} }
} }

View file

@ -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]);
} }
/** /**

View file

@ -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');

View file

@ -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');

View file

@ -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.

View file

@ -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']);
}
}

View file

@ -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

View file

@ -7,38 +7,59 @@
</button> </button>
</li> </li>
</ul> </ul>
<div class="stretch-to-bottom"> <div class="stretch-to-bottom row">
<div class="form-row" ng-class="{'has-error': errors.display_name != null}"> <div class="col-md-4">
<label class="strong" for="display_name">Display Name</label> <div class="form-row" ng-class="{'has-error': errors.display_name != null}">
<input type="text" ng-disabled="isSaving" ng-change="touchModel()" placeholder="Display Name" id="display_name" ng-model="settings.display_name" /> <label class="strong" for="display_name">Display Name</label>
<div class="error">{{errors.display_name}}</div> <input type="text" ng-disabled="isSaving" ng-change="touchModel()" placeholder="Display Name" id="display_name" ng-model="settings.display_name" />
</div> <div class="error">{{errors.display_name}}</div>
</div>
<div class="form-row" ng-class="{'has-error': errors.slug != null}">
<label class="strong" for="slug">Slug (your profile URL: https://pony.fm/{{settings.slug}})</label> <div class="form-row" ng-class="{'has-error': errors.slug != null}">
<input type="text" ng-disabled="isSaving" ng-change="touchModel()" placeholder="slug" id="slug" ng-model="settings.slug" /> <label class="strong" for="slug">Slug (your profile URL: https://pony.fm/{{settings.slug}})</label>
<div class="error">{{errors.slug}}</div> <input type="text" ng-disabled="isSaving" ng-change="touchModel()" placeholder="slug" id="slug" ng-model="settings.slug" />
</div> <div class="error">{{errors.slug}}</div>
</div>
<div class="form-row">
<label for="can_see_explicit_content" class="strong"><input ng-change="touchModel()" ng-disabled="isLoading" id="can_see_explicit_content" type="checkbox" ng-model="settings.can_see_explicit_content" /> Can See Explicit Content</label> <div class="form-row">
</div> <label for="can_see_explicit_content" class="strong"><input ng-change="touchModel()" ng-disabled="isLoading" id="can_see_explicit_content" type="checkbox" ng-model="settings.can_see_explicit_content" /> Can See Explicit Content</label>
</div>
<div class="form-row" ng-class="{'has-error': errors.bio != null}">
<label class="strong" for="bio">Bio</label> <div class="form-row" ng-class="{'has-error': errors.bio != null}">
<textarea id="bio" placeholder="bio (optional)" ng-model="settings.bio" ng-disabled="isLoading" ng-change="touchModel()"></textarea> <label class="strong" for="bio">Bio</label>
<div class="error">{{errors.description}}</div> <textarea id="bio" placeholder="bio (optional)" ng-model="settings.bio" ng-disabled="isLoading" ng-change="touchModel()"></textarea>
</div> <div class="error">{{errors.description}}</div>
</div>
<div class="form-row" ng-class="{'has-error': errors.avatar != null || errors.gravatar != null}">
<label for="uses_gravatar" class="strong"> <div class="form-row" ng-class="{'has-error': errors.avatar != null || errors.gravatar != null}">
<input ng-change="touchModel()" ng-disabled="isLoading" id="uses_gravatar" type="checkbox" ng-model="settings.uses_gravatar" /> Use Gravatar <label for="uses_gravatar" class="strong">
</label> <input ng-change="touchModel()" ng-disabled="isLoading" id="uses_gravatar" type="checkbox" ng-model="settings.uses_gravatar" /> Use Gravatar
<div ng-show="!settings.uses_gravatar"> </label>
<pfm-image-upload set-image="setAvatar" image="settings.avatar_url" user-id="settings.id"></pfm-image-upload> <div ng-show="!settings.uses_gravatar">
<pfm-image-upload set-image="setAvatar" image="settings.avatar_url" user-id="settings.id"></pfm-image-upload>
</div>
<input type="text" ng-disabled="isSaving" ng-change="touchModel()" ng-show="settings.uses_gravatar" placeholder="Gravatar Email" ng-model="settings.gravatar" />
<div class="error" ng-show="errors.avatar != null">{{errors.avatar}}</div>
<div class="error" ng-show="errors.gravatar != null">{{errors.gravatar}}</div>
</div> </div>
<input type="text" ng-disabled="isSaving" ng-change="touchModel()" ng-show="settings.uses_gravatar" placeholder="Gravatar Email" ng-model="settings.gravatar" />
<div class="error" ng-show="errors.avatar != null">{{errors.avatar}}</div>
<div class="error" ng-show="errors.gravatar != null">{{errors.gravatar}}</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&#39;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>

View file

@ -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

View file

@ -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');