Merge pull request #116 from Poniverse/feature/email_notifications

This merge is being done to avoid letting the email notifications branch diverge too far from master. While email notifications are still unfinished, their implementation at this point co-exists peacefully with the existing on-site notifications.
This commit is contained in:
Peter Deltchev 2016-12-23 06:50:50 -08:00 committed by GitHub
commit c2dbfd792c
38 changed files with 1529 additions and 654 deletions

View file

@ -0,0 +1,55 @@
<?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/>.
*/
namespace Poniverse\Ponyfm\Http\Controllers;
use App;
use DB;
use Poniverse\Ponyfm\Models\Email;
use Poniverse\Ponyfm\Models\EmailSubscription;
// TODO: #25 - finish these endpoints and secure them properly
class NotificationsController extends Controller {
public function getEmailClick($emailKey) {
App::abort(403, "This isn't implemented yet!");
$emailKey = decrypt($emailKey);
/** @var Email $email */
$email = Email::findOrFail($emailKey);
DB::transaction(function() use ($email) {
$email->emailClicks()->create(['ip_address' => \Request::ip()]);
$email->notification->is_read = true;
$email->notification->save();
});
return redirect($email->getActivity()->url);
}
public function getEmailUnsubscribe($subscriptionKey) {
App::abort(403, "This isn't implemented yet!");
$subscriptionId = decrypt($subscriptionKey);
$subscription = EmailSubscription::findOrFail($subscriptionId);
return var_export($subscription);
}
}

View file

@ -24,6 +24,7 @@ use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Queue\InteractsWithQueue;
use Poniverse\Ponyfm\Jobs\Job;
use Poniverse\Ponyfm\Library\Notifications\Drivers\AbstractDriver;
use Poniverse\Ponyfm\Library\Notifications\Drivers\EmailDriver;
use Poniverse\Ponyfm\Library\Notifications\Drivers\NativeDriver;
use Poniverse\Ponyfm\Library\Notifications\Drivers\PonyfmDriver;
use Poniverse\Ponyfm\Models\User;
@ -64,6 +65,9 @@ class SendNotifications extends Job implements ShouldQueue
//NativeDriver::class
];
// NOTE: PonyfmDriver MUST execute before any other drivers; it creates
// the Notification records that the other drivers depend on!
foreach ($drivers as $driver) {
/** @var $driver AbstractDriver */
$driver = new $driver;

View file

@ -31,7 +31,16 @@ abstract class AbstractDriver implements NotificationHandler
public function __construct()
{
$this->recipientFinder = new RecipientFinder(get_class($this));
$notificationDriverClass = get_class($this);
switch ($notificationDriverClass) {
case EmailDriver::class:
case PonyfmDriver::class:
$this->recipientFinder = new RecipientFinder(get_class($this));
break;
default:
throw new \Exception("Invalid notification driver!");
}
}
/**

View file

@ -20,11 +20,14 @@
namespace Poniverse\Ponyfm\Library\Notifications\Drivers;
use ArrayAccess;
use Carbon\Carbon;
use Log;
use Mail;
use Poniverse\Ponyfm\Contracts\Favouritable;
use Poniverse\Ponyfm\Mail\BaseNotification;
use Poniverse\Ponyfm\Models\Activity;
use Poniverse\Ponyfm\Models\Comment;
use Poniverse\Ponyfm\Models\Email;
use Poniverse\Ponyfm\Models\Notification;
use Poniverse\Ponyfm\Models\Playlist;
use Poniverse\Ponyfm\Models\Track;
@ -35,21 +38,39 @@ class PonyfmDriver extends AbstractDriver
/**
* A helper method for bulk insertion of notification records.
*
* @param int $activityId
* @param Activity $activity
* @param User[] $recipients collection of {@link User} objects
*/
private function insertNotifications(int $activityId, $recipients)
private function insertNotifications(Activity $activity, $recipients)
{
$notifications = [];
foreach ($recipients as $recipient) {
$notifications[] = [
'activity_id' => $activityId,
'activity_id' => $activity->id,
'user_id' => $recipient->id
];
}
Notification::insert($notifications);
}
/**
* Sends out an email about the given activity to the given set of users.
*
* @param Activity $activity
* @param User[] $recipients collection of {@link User} objects
*/
private function sendEmails(Activity $activity, $recipients) {
foreach ($recipients as $recipient) {
/** @var Notification $notification */
$notification = $activity->notifications->where('user_id', $recipient->id)->first();
/** @var Email $email */
$email = $notification->email()->create([]);
Log::debug("Attempting to send an email about notification {$notification->id} to {$recipient->email}.");
Mail::to($recipient->email)->queue(BaseNotification::factory($activity, $email));
}
}
/**
* @inheritdoc
*/
@ -63,7 +84,11 @@ class PonyfmDriver extends AbstractDriver
'resource_id' => $track->id,
]);
$this->insertNotifications($activity->id, $this->getRecipients(__FUNCTION__, func_get_args()));
$recipientsQuery = $this->getRecipients(__FUNCTION__, func_get_args());
if (NULL !== $recipientsQuery) {
$this->insertNotifications($activity, $recipientsQuery->get());
$this->sendEmails($activity, $recipientsQuery->withEmailSubscriptionFor(Activity::TYPE_PUBLISHED_TRACK)->get());
}
}
/**
@ -79,9 +104,16 @@ class PonyfmDriver extends AbstractDriver
'resource_id' => $playlist->id,
]);
$this->insertNotifications($activity->id, $this->getRecipients(__FUNCTION__, func_get_args()));
$recipientsQuery = $this->getRecipients(__FUNCTION__, func_get_args());
if (NULL !== $recipientsQuery) {
$this->insertNotifications($activity, $recipientsQuery->get());
$this->sendEmails($activity, $recipientsQuery->withEmailSubscriptionFor(Activity::TYPE_PUBLISHED_PLAYLIST)->get());
}
}
/**
* @inheritdoc
*/
public function newFollower(User $userBeingFollowed, User $follower)
{
$activity = Activity::create([
@ -92,7 +124,11 @@ class PonyfmDriver extends AbstractDriver
'resource_id' => $userBeingFollowed->id,
]);
$this->insertNotifications($activity->id, $this->getRecipients(__FUNCTION__, func_get_args()));
$recipientsQuery = $this->getRecipients(__FUNCTION__, func_get_args());
if (NULL !== $recipientsQuery) {
$this->insertNotifications($activity, $recipientsQuery->get());
$this->sendEmails($activity, $recipientsQuery->withEmailSubscriptionFor(Activity::TYPE_NEW_FOLLOWER)->get());
}
}
/**
@ -108,7 +144,11 @@ class PonyfmDriver extends AbstractDriver
'resource_id' => $comment->id,
]);
$this->insertNotifications($activity->id, $this->getRecipients(__FUNCTION__, func_get_args()));
$recipientsQuery = $this->getRecipients(__FUNCTION__, func_get_args());
if (NULL !== $recipientsQuery) {
$this->insertNotifications($activity, $recipientsQuery->get());
$this->sendEmails($activity, $recipientsQuery->withEmailSubscriptionFor(Activity::TYPE_NEW_COMMENT)->get());
}
}
/**
@ -124,6 +164,10 @@ class PonyfmDriver extends AbstractDriver
'resource_id' => $entityBeingFavourited->id,
]);
$this->insertNotifications($activity->id, $this->getRecipients(__FUNCTION__, func_get_args()));
$recipientsQuery = $this->getRecipients(__FUNCTION__, func_get_args());
if (NULL !== $recipientsQuery) {
$this->insertNotifications($activity, $recipientsQuery->get());
$this->sendEmails($activity, $recipientsQuery->withEmailSubscriptionFor(Activity::TYPE_CONTENT_FAVOURITED)->get());
}
}
}

View file

@ -20,12 +20,14 @@
namespace Poniverse\Ponyfm\Library\Notifications;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Foundation\Bus\DispatchesJobs;
use Poniverse\Ponyfm\Contracts\Favouritable;
use Poniverse\Ponyfm\Contracts\NotificationHandler;
use Poniverse\Ponyfm\Jobs\SendNotifications;
use Poniverse\Ponyfm\Library\Notifications\Drivers\NativeDriver;
use Poniverse\Ponyfm\Library\Notifications\Drivers\PonyfmDriver;
use Poniverse\Ponyfm\Models\Activity;
use Poniverse\Ponyfm\Models\Comment;
use Poniverse\Ponyfm\Models\Playlist;
use Poniverse\Ponyfm\Models\Subscription;
@ -37,7 +39,8 @@ use Poniverse\Ponyfm\Models\User;
* @package Poniverse\Ponyfm\Library\Notifications
*
* This class returns a list of users who are to receive a particular notification.
* It is instantiated on a per-driver basis.
* It is instantiated on a per-driver basis. Its methods return Eloquent query
* objects for the PonyfmDriver.
*/
class RecipientFinder implements NotificationHandler
{
@ -63,7 +66,8 @@ class RecipientFinder implements NotificationHandler
{
switch ($this->notificationDriver) {
case PonyfmDriver::class:
return $track->user->followers;
return $track->user->followers();
case NativeDriver::class:
$followerIds = [];
$subIds = [];
@ -91,7 +95,8 @@ class RecipientFinder implements NotificationHandler
{
switch ($this->notificationDriver) {
case PonyfmDriver::class:
return $playlist->user->followers;
return $playlist->user->followers();
case NativeDriver::class:
$followerIds = [];
$subIds = [];
@ -119,7 +124,8 @@ class RecipientFinder implements NotificationHandler
{
switch ($this->notificationDriver) {
case PonyfmDriver::class:
return [$userBeingFollowed];
return $this->queryForUser($userBeingFollowed);
case NativeDriver::class:
return Subscription::where('user_id', '=', $userBeingFollowed->id)->get();
default:
@ -136,8 +142,8 @@ class RecipientFinder implements NotificationHandler
case PonyfmDriver::class:
return
$comment->user->id === $comment->resource->user->id
? []
: [$comment->resource->user];
? NULL
: $this->queryForUser($comment->resource->user);
case NativeDriver::class:
return Subscription::where('user_id', '=', $comment->resource->user->id)->get();
default:
@ -154,12 +160,23 @@ class RecipientFinder implements NotificationHandler
case PonyfmDriver::class:
return
$favouriter->id === $entityBeingFavourited->user->id
? []
: [$entityBeingFavourited->user];
? NULL
: $this->queryForUser($entityBeingFavourited->user);
case NativeDriver::class:
return Subscription::where('user_id', '=', $entityBeingFavourited->user->id)->get();
default:
return $this->fail();
}
}
/**
* Helper function that returns an Eloquent query instance that will return
* a specific user when executed.
*
* @param User $user
* @return \Eloquent|Builder
*/
private function queryForUser(User $user):Builder {
return User::where('id', '=', $user->id);
}
}

View file

@ -0,0 +1,135 @@
<?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/>.
*/
namespace Poniverse\Ponyfm\Mail;
use Illuminate\Bus\Queueable;
use Illuminate\Mail\Mailable;
use Illuminate\Queue\SerializesModels;
use Poniverse\Ponyfm\Models\Activity;
use Poniverse\Ponyfm\Models\Email;
abstract class BaseNotification extends Mailable {
use Queueable, SerializesModels;
/** @var Email */
protected $emailRecord;
/** @var \Poniverse\Ponyfm\Models\Notification */
protected $notificationRecord;
/** @var \Poniverse\Ponyfm\Models\Activity */
protected $activityRecord;
/** @var \Poniverse\Ponyfm\Models\User */
protected $initiatingUser;
/**
* Create a new message instance.
*
* @param Email $email
*/
public function __construct(Email $email) {
$this->emailRecord = $email;
$this->notificationRecord = $email->notification;
$this->activityRecord = $email->notification->activity;
$this->initiatingUser = $email->notification->activity->initiatingUser;
}
/**
* Factory method that instantiates the appropriate {@link BaseNotification}
* subclass for the given activity type and {@link Email} record.
*
* @param Activity $activity
* @param Email $email
* @return BaseNotification
*/
static public function factory(Activity $activity, Email $email): BaseNotification {
switch ($activity->activity_type) {
case Activity::TYPE_NEWS:
break;
case Activity::TYPE_PUBLISHED_TRACK:
return new NewTrack($email);
case Activity::TYPE_PUBLISHED_ALBUM:
break;
case Activity::TYPE_PUBLISHED_PLAYLIST:
return new NewPlaylist($email);
case Activity::TYPE_NEW_FOLLOWER:
return new NewFollower($email);
case Activity::TYPE_NEW_COMMENT:
return new NewComment($email);
case Activity::TYPE_CONTENT_FAVOURITED:
return new ContentFavourited($email);
default:
break;
}
throw new \InvalidArgumentException("Email notifications for activity type {$activity->activity_type} are not implemented!");
}
/**
* Build the message.
*
* @return $this
*/
abstract public function build();
/**
* Generates an unsubscribe URL unique to the user.
*
* @return string
*/
protected function generateUnsubscribeUrl() {
$subscriptionKey = encrypt($this->emailRecord->getSubscription()->id);
return route('email:unsubscribe', ['subscriptionKey' => $subscriptionKey]);
}
/**
* Generates a trackable URL to the content item this email is about.
*
* @return string
*/
protected function generateNotificationUrl() {
$emailKey = encrypt($this->emailRecord->id);
return route('email:click', ['emailKey' => $emailKey]);
}
/**
* Helper method to eliminate duplication between different types of
* notifications. Use it inside the build() method on this class's children.
*
* Note that data common to all notification types is merged into the
* template variable array.
*
* @param string $templateName
* @param string $subject
* @param array $extraVariables
* @return $this
*/
protected function renderEmail(string $templateName, string $subject, array $extraVariables) {
return $this
->subject($subject)
->view("emails.notifications.{$templateName}")
->text("emails.notifications.{$templateName}_plaintext")
->with(array_merge($extraVariables, [
'notificationUrl' => $this->generateNotificationUrl(),
'unsubscribeUrl' => $this->generateUnsubscribeUrl()
]));
}
}

View file

@ -0,0 +1,40 @@
<?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/>.
*/
namespace Poniverse\Ponyfm\Mail;
class ContentFavourited extends BaseNotification
{
/**
* @inheritdoc
*/
public function build()
{
$creatorName = $this->initiatingUser->display_name;
return $this->renderEmail(
'content-favourited',
$this->activityRecord->text, [
'creatorName' => $creatorName,
'resourceType' => $this->activityRecord->getResourceTypeString(),
'resourceTitle' => $this->activityRecord->resource->title,
]);
}
}

53
app/Mail/NewComment.php Normal file
View file

@ -0,0 +1,53 @@
<?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/>.
*/
namespace Poniverse\Ponyfm\Mail;
use Poniverse\Ponyfm\Models\User;
class NewComment extends BaseNotification
{
/**
* @inheritdoc
*/
public function build()
{
$creatorName = $this->initiatingUser->display_name;
// Profile comments get a different template and subject line from
// other types of comments.
if ($this->activityRecord->getResourceTypeString() === User::class) {
return $this->renderEmail(
'new-comment-profile',
$this->activityRecord->text, [
'creatorName' => $creatorName,
]);
} else {
return $this->renderEmail(
'new-comment-content',
$this->activityRecord->text, [
'creatorName' => $creatorName,
'resourceType' => $this->activityRecord->getResourceTypeString(),
'resourceTitle' => $this->activityRecord->resource->resource->title,
]);
}
}
}

39
app/Mail/NewFollower.php Normal file
View file

@ -0,0 +1,39 @@
<?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/>.
*/
namespace Poniverse\Ponyfm\Mail;
class NewFollower extends BaseNotification
{
/**
* @inheritdoc
*/
public function build()
{
$creatorName = $this->initiatingUser->display_name;
return $this->renderEmail(
'new-follower',
"{$creatorName} is now following you on Pony.fm!",
[
'creatorName' => $creatorName,
]);
}
}

41
app/Mail/NewPlaylist.php Normal file
View file

@ -0,0 +1,41 @@
<?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/>.
*/
namespace Poniverse\Ponyfm\Mail;
class NewPlaylist extends BaseNotification
{
/**
* @inheritdoc
*/
public function build()
{
$creatorName = $this->initiatingUser->display_name;
$playlistTitle = $this->activityRecord->resource->title;
return $this->renderEmail(
'new-playlist',
"{$creatorName} created a playlist, \"{$playlistTitle}\"!",
[
'creatorName' => $creatorName,
'playlistTitle' => $playlistTitle,
]);
}
}

41
app/Mail/NewTrack.php Normal file
View file

@ -0,0 +1,41 @@
<?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/>.
*/
namespace Poniverse\Ponyfm\Mail;
class NewTrack extends BaseNotification
{
/**
* @inheritdoc
*/
public function build()
{
$creatorName = $this->initiatingUser->display_name;
$trackTitle = $this->activityRecord->resource->title;
return $this->renderEmail(
'new-track',
"{$creatorName} published \"{$trackTitle}\"!",
[
'creatorName' => $creatorName,
'trackTitle' => $trackTitle,
]);
}
}

View file

@ -66,6 +66,11 @@ class Activity extends Model
'resource_id' => 'integer',
];
/**
* These constants are stored in the "activity_types" table for the purpose
* of referential data integrity. Any additions or changes to them MUST
* include a database migration to apply the changes to that table as well.
*/
const TYPE_NEWS = 1;
const TYPE_PUBLISHED_TRACK = 2;
const TYPE_PUBLISHED_ALBUM = 3;
@ -206,6 +211,39 @@ class Activity extends Model
}
/**
* Returns a string representing the type of resource this activity is about
* for use in human-facing notification text.
*
* @return string
* @throws \Exception
*/
public function getResourceTypeString():string
{
switch($this->activity_type) {
case static::TYPE_NEW_COMMENT:
if ($this->isProfileComment()) {
return $this->resource->getResourceType();
} else {
return $this->resource->resource->getResourceType();
}
case static::TYPE_CONTENT_FAVOURITED:
return $this->resource->getResourceType();
}
throw new \Exception("Unknown activity type {$this->activity_type} - cannot determine resource type.");
}
/**
* @return bool
*/
public function isProfileComment():bool {
return static::TYPE_NEW_COMMENT === $this->activity_type &&
User::class === $this->resource->getResourceClass();
}
/**
* The string this method generates is used for email subject lines as well
* as on-site notifications.
*
* @return string human-readable Markdown string describing this notification
* @throws \Exception
*/
@ -226,17 +264,16 @@ class Activity extends Model
return "{$this->initiatingUser->display_name} is now following you!";
case static::TYPE_NEW_COMMENT:
// Is this a profile comment?
if ($this->resource_type === User::class) {
if ($this->isProfileComment()) {
return "{$this->initiatingUser->display_name} left a comment on your profile!";
// Must be a content comment.
// If it's not a profile comment, it must be a content comment.
} else {
return "{$this->initiatingUser->display_name} left a comment on your {$this->resource->resource->getResourceType()}, {$this->resource->resource->title}!";
return "{$this->initiatingUser->display_name} left a comment on your {$this->getResourceTypeString()}, \"{$this->resource->resource->title}\"!";
}
case static::TYPE_CONTENT_FAVOURITED:
return "{$this->initiatingUser->display_name} favourited your {$this->resource->getResourceType()}, {$this->resource->title}!";
return "{$this->initiatingUser->display_name} favourited your {$this->getResourceTypeString()}, \"{$this->resource->title}\"!";
default:
throw new \Exception('This activity\'s activity type is unknown!');

View file

@ -144,6 +144,15 @@ class Comment extends Model
}
}
/**
* Returns the class name of the object that this is a comment on.
*
* @return string
*/
public function getResourceClass():string {
return get_class($this->resource);
}
public function delete()
{
DB::transaction(function () {

69
app/Models/Email.php Normal file
View file

@ -0,0 +1,69 @@
<?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/>.
*/
namespace Poniverse\Ponyfm\Models;
use Alsofronie\Uuid\UuidModelTrait;
use Illuminate\Database\Eloquent\Model;
use Poniverse\Ponyfm\Models\Notification;
/**
* Poniverse\Ponyfm\Models\Email
*
* @property string $id
* @property integer $notification_id
* @property \Carbon\Carbon $created_at
* @property \Carbon\Carbon $updated_at
* @property-read \Poniverse\Ponyfm\Models\Notification $notification
* @property-read \Illuminate\Database\Eloquent\Collection|\Poniverse\Ponyfm\Models\EmailClick[] $emailClicks
* @method static \Illuminate\Database\Query\Builder|\Poniverse\Ponyfm\Models\Email whereId($value)
* @method static \Illuminate\Database\Query\Builder|\Poniverse\Ponyfm\Models\Email whereNotificationId($value)
* @method static \Illuminate\Database\Query\Builder|\Poniverse\Ponyfm\Models\Email whereCreatedAt($value)
* @method static \Illuminate\Database\Query\Builder|\Poniverse\Ponyfm\Models\Email whereUpdatedAt($value)
* @mixin \Eloquent
*/
class Email extends Model
{
use UuidModelTrait;
public function notification() {
return $this->belongsTo(Notification::class, 'notification_id', 'id', 'notifications');
}
public function emailClicks() {
return $this->hasMany(EmailClick::class, 'email_id', 'id');
}
public function getActivity():Activity {
return $this->notification->activity;
}
public function getUser():User {
return $this->notification->recipient;
}
public function getSubscription():EmailSubscription {
return $this
->getUser()
->emailSubscriptions()
->where('activity_type', $this->getActivity()->activity_type)
->first();
}
}

51
app/Models/EmailClick.php Normal file
View file

@ -0,0 +1,51 @@
<?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/>.
*/
namespace Poniverse\Ponyfm\Models;
use Alsofronie\Uuid\UuidModelTrait;
use Illuminate\Database\Eloquent\Model;
/**
* Poniverse\Ponyfm\Models\EmailClick
*
* @property string $id
* @property string $email_id
* @property string $ip_address
* @property \Carbon\Carbon $created_at
* @property \Carbon\Carbon $updated_at
* @property-read \Poniverse\Ponyfm\Models\Email $email
* @method static \Illuminate\Database\Query\Builder|\Poniverse\Ponyfm\Models\EmailClick whereId($value)
* @method static \Illuminate\Database\Query\Builder|\Poniverse\Ponyfm\Models\EmailClick whereEmailId($value)
* @method static \Illuminate\Database\Query\Builder|\Poniverse\Ponyfm\Models\EmailClick whereIpAddress($value)
* @method static \Illuminate\Database\Query\Builder|\Poniverse\Ponyfm\Models\EmailClick whereCreatedAt($value)
* @method static \Illuminate\Database\Query\Builder|\Poniverse\Ponyfm\Models\EmailClick whereUpdatedAt($value)
* @mixin \Eloquent
*/
class EmailClick extends Model
{
use UuidModelTrait;
protected $fillable = ['ip_address'];
public function email() {
return $this->belongsTo(Email::class, 'email_id', 'id', 'emails');
}
}

View file

@ -0,0 +1,53 @@
<?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/>.
*/
namespace Poniverse\Ponyfm\Models;
use Alsofronie\Uuid\UuidModelTrait;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
use Poniverse\Ponyfm\Models\User;
/**
* Poniverse\Ponyfm\EmailSubscription
*
* @property string $id
* @property integer $user_id
* @property integer $activity_type
* @property \Carbon\Carbon $created_at
* @property \Carbon\Carbon $updated_at
* @property string $deleted_at
* @property-read \Poniverse\Ponyfm\Models\User $user
* @method static \Illuminate\Database\Query\Builder|\Poniverse\Ponyfm\Models\EmailSubscription whereId($value)
* @method static \Illuminate\Database\Query\Builder|\Poniverse\Ponyfm\Models\EmailSubscription whereUserId($value)
* @method static \Illuminate\Database\Query\Builder|\Poniverse\Ponyfm\Models\EmailSubscription whereActivityType($value)
* @method static \Illuminate\Database\Query\Builder|\Poniverse\Ponyfm\Models\EmailSubscription whereCreatedAt($value)
* @method static \Illuminate\Database\Query\Builder|\Poniverse\Ponyfm\Models\EmailSubscription whereUpdatedAt($value)
* @method static \Illuminate\Database\Query\Builder|\Poniverse\Ponyfm\Models\EmailSubscription whereDeletedAt($value)
* @mixin \Eloquent
*/
class EmailSubscription extends Model
{
use UuidModelTrait, SoftDeletes;
public function user() {
return $this->belongsTo(User::class, 'user_id', 'id', 'users');
}
}

View file

@ -31,6 +31,7 @@ use Illuminate\Database\Eloquent\Model;
* @property integer $user_id
* @property boolean $is_read
* @property-read \Poniverse\Ponyfm\Models\Activity $activity
* @property-read \Poniverse\Ponyfm\Models\Email $email
* @property-read \Poniverse\Ponyfm\Models\User $recipient
* @method static \Illuminate\Database\Query\Builder|\Poniverse\Ponyfm\Models\Notification forUser($user)
* @method static \Illuminate\Database\Query\Builder|\Poniverse\Ponyfm\Models\Notification whereId($value)
@ -60,6 +61,11 @@ class Notification extends Model
return $this->belongsTo(User::class, 'user_id', 'id');
}
public function email()
{
return $this->hasOne(Email::class, 'notification_id', 'id');
}
/**
* This scope grabs eager-loaded notifications for the given user.
*

View file

@ -73,6 +73,8 @@ use Venturecraft\Revisionable\RevisionableTrait;
* @property-read mixed $user
* @property-read \Illuminate\Database\Eloquent\Collection|\Poniverse\Ponyfm\Models\Activity[] $activities
* @property-read \Illuminate\Database\Eloquent\Collection|\Poniverse\Ponyfm\Models\Activity[] $notificationActivities
* @property-read \Illuminate\Database\Eloquent\Collection|\Poniverse\Ponyfm\Models\Email[] $emails
* @property-read \Illuminate\Database\Eloquent\Collection|\Poniverse\Ponyfm\Models\EmailSubscription[] $emailSubscriptions
* @method static \Illuminate\Database\Query\Builder|\Poniverse\Ponyfm\Models\User whereId($value)
* @method static \Illuminate\Database\Query\Builder|\Poniverse\Ponyfm\Models\User whereDisplayName($value)
* @method static \Illuminate\Database\Query\Builder|\Poniverse\Ponyfm\Models\User whereUsername($value)
@ -91,6 +93,7 @@ use Venturecraft\Revisionable\RevisionableTrait;
* @method static \Illuminate\Database\Query\Builder|\Poniverse\Ponyfm\Models\User whereRememberToken($value)
* @method static \Illuminate\Database\Query\Builder|\Poniverse\Ponyfm\Models\User whereIsArchived($value)
* @method static \Illuminate\Database\Query\Builder|\Poniverse\Ponyfm\Models\User whereDisabledAt($value)
* @method static \Illuminate\Database\Query\Builder|\Poniverse\Ponyfm\Models\User withEmailSubscriptionFor($activityType)
* @mixin \Eloquent
*/
class User extends Model implements AuthenticatableContract, CanResetPasswordContract, \Illuminate\Contracts\Auth\Access\Authorizable, Searchable, Commentable
@ -126,6 +129,19 @@ class User extends Model implements AuthenticatableContract, CanResetPasswordCon
return !$query;
}
/**
* Returns users with an email subscription to the given activity type.
*
* @param $query
* @param int $activityType one of the TYPE_* constants in the Activity class
* @return mixed
*/
public function scopeWithEmailSubscriptionFor($query, int $activityType) {
return $query->whereHas('emailSubscriptions', function ($query) use ($activityType) {
$query->where('activity_type', $activityType);
});
}
/**
* Takes the given string, slugifies it, and increments a counter if needed
* to generate a unique slug version of it.
@ -239,6 +255,16 @@ class User extends Model implements AuthenticatableContract, CanResetPasswordCon
return $this->hasManyThrough(Activity::class, Notification::class, 'user_id', 'notification_id', 'id');
}
public function emails()
{
return $this->hasManyThrough(Email::class, Notification::class, 'user_id', 'notification_id', 'id');
}
public function emailSubscriptions()
{
return $this->hasMany(EmailSubscription::class, 'user_id', 'id');
}
public function getIsArchivedAttribute()
{
return (bool) $this->attributes['is_archived'];

View file

@ -24,7 +24,9 @@
"predis/predis": "^1.0",
"ksubileau/color-thief-php": "^1.3",
"graham-campbell/exceptions": "^9.1",
"minishlink/web-push": "^1.0"
"minishlink/web-push": "^1.0",
"laravel/legacy-encrypter": "^1.0",
"alsofronie/eloquent-uuid": "^1.0"
},
"require-dev": {
"fzaninotto/faker": "~1.4",

1125
composer.lock generated

File diff suppressed because it is too large Load diff

View file

@ -54,7 +54,7 @@ return [
|
*/
'from' => ['address' => null, 'name' => null],
'from' => ['address' => 'hello@pony.fm', 'name' => 'Pony.fm'],
/*
|--------------------------------------------------------------------------

View file

@ -0,0 +1,107 @@
<?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 CreateEmailNotificationTables extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
DB::transaction(function(){
// This table is used to enforce referential data integrity
// for the polymorphic "activity" table.
Schema::create('activity_types', function (Blueprint $table) {
$table->unsignedTinyInteger('activity_type')->primary();
$table->string('description');
});
DB::table('activity_types')->insert([
['activity_type' => 1, 'description' => 'news'],
['activity_type' => 2, 'description' => 'followee published a track'],
['activity_type' => 3, 'description' => 'followee published an album'],
['activity_type' => 4, 'description' => 'followee published a playlist'],
['activity_type' => 5, 'description' => 'new follower'],
['activity_type' => 6, 'description' => 'new comment'],
['activity_type' => 7, 'description' => 'new favourite'],
]);
Schema::table('activities', function (Blueprint $table) {
$table->foreign('activity_type')->references('activity_type')->on('activity_types');
});
Schema::create('email_subscriptions', function (Blueprint $table) {
$table->uuid('id')->primary();
$table->unsignedInteger('user_id');
$table->unsignedInteger('activity_type');
$table->timestamps();
$table->softDeletes();
$table->foreign('user_id')->references('id')->on('users');
$table->foreign('activity_type')->references('activity_type')->on('activity_types');
});
Schema::create('emails', function (Blueprint $table) {
$table->uuid('id')->primary();
// Clicking the email link should mark the corresponding on-site notification as read.
$table->unsignedBigInteger('notification_id')->index();
$table->timestamps();
$table->foreign('notification_id')->references('id')->on('notifications');
});
Schema::create('email_clicks', function (Blueprint $table) {
$table->uuid('id')->primary();
$table->uuid('email_id')->index();
$table->ipAddress('ip_address');
$table->timestamps();
$table->foreign('email_id')->references('id')->on('emails');
});
});
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
DB::transaction(function() {
Schema::drop('email_clicks');
Schema::drop('emails');
Schema::drop('email_subscriptions');
Schema::table('activities', function (Blueprint $table) {
$table->dropForeign('activities_activity_type_foreign');
});
Schema::drop('activity_types');
});
}
}

View file

@ -17,12 +17,12 @@ The `Notification` facade is used to send notifications as follows:
```php
use Notification;
// Something happens, like a newtrack getting published.
// Something happens, like a new track getting published.
$track = new Track();
...
// The "something" is done happening! Time to send a notification.
Notification::publishedTrack($track);
Notification::publishedNewTrack($track);
```
This facade has a method for every notification type, drawn from the
@ -45,10 +45,18 @@ Adding new notification types
- [`RecipientFinder`](../app/Library/Notifications/RecipientFinder.php)
- [`PonyfmDriver`](../app/Library/Notifications/PonyfmDriver.php)
3. Call the new method on the `Notification` facade from wherever the
3. Create a migration to add the new notification type to the `activity_types`
table. Add a constant for it to the [`Activity`](../app/Models/Activity.php)
class.
3. Ensure you create HTML and plaintext templates, as well as a subclass of
[`BaseNotification`](../app/Mail/BaseNotification.php) for the email version
of the notification.
4. Call the new method on the `Notification` facade from wherever the
new notification gets triggered.
4. Implement any necessary logic for the new notification type in the
5. Implement any necessary logic for the new notification type in the
[`Activity`](../app/Models/Activity.php) model.
@ -92,3 +100,25 @@ There's one exception to the use of `NotificationHandler` - the
data we store about an activity in the database to a notification's API
representation had to go somewhere, and using the `NotificationHandler`
interface here would have made this logic a lot more obtuse.
### Data flow
1. Some action that triggers a notification calls the `NotificationManager`
facade.
2. An asynchronous job is kicked off that figures out how to send the
notification.
3. An `Activity` record is created for the action.
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
notifications and cannot be disabled.
5. Depending on subscription preferences, push and email notifications will be
sent out as well, each creating their own respective database records. These
are linked to a `Notification` record for unified read/unread tracking.
6. A `Notification` record is marked read when it is viewed on-site or any other
notification type associated with it (like an email or push notification) is
clicked.

View file

@ -0,0 +1,13 @@
@yield('content')
<hr>
<p><a href="{{ $unsubscribeUrl }}" target="_blank">Unsubscribe from this kind of email</a></p>
<address style="font-size:85%;">
Poniverse<br>
248-1641 Lonsdale Avenue<br>
North Vancouver<br>
BC V7M 2J5<br>
Canada
</address>

View file

@ -0,0 +1,10 @@
@yield('content')
---
Unsubscribe from this kind of email:
{{ $unsubscribeUrl }}
Poniverse
248-1641 Lonsdale Avenue
North Vancouver
BC V7M 2J5

View file

@ -0,0 +1,9 @@
@extends('emails.notifications._layout')
@section('content')
<p>
{{ $creatorName }} favourited your {{ $resourceType }},
<em><a href="{{ $notificationUrl }}" target="_blank">{{ $resourceTitle }}</a></em>
Yay!
</p>
@endsection

View file

@ -0,0 +1,8 @@
@extends('emails.notifications._layout_plaintext')
@section('content')
{{ $creatorName }} favourited your {{ $resourceType }}, "{{ $resourceTitle }}". Yay!
Here's a link to the {{ $resourceType }}:
{{ $notificationUrl }}
@endsection

View file

@ -0,0 +1,9 @@
@extends('emails.notifications._layout')
@section('content')
<p>
{{ $creatorName }} left a comment on your {{ $resourceType }},
<a href="{{ $notificationUrl }}" target="_blank"><em>{{ $resourceTitle }}</em></a>!
<a href="{{ $notificationUrl }}" target="_blank">Visit it</a> to read the comment and reply.
</p>
@endsection

View file

@ -0,0 +1,8 @@
@extends('emails.notifications._layout_plaintext')
@section('content')
{{ $creatorName }} left a comment on your {{ $resourceType }}, "{{ $resourceTitle }}"!
Visit the following link to read the comment and reply:
{{ $notificationUrl }}
@endsection

View file

@ -0,0 +1,9 @@
@extends('emails.notifications._layout')
@section('content')
<p>
{{ $creatorName }} left a comment on your Pony.fm profile!
<a href="{{ $notificationUrl }}" target="_blank">Visit your profile</a> to
read it and reply.
</p>
@endsection

View file

@ -0,0 +1,8 @@
@extends('emails.notifications._layout_plaintext')
@section('content')
{{ $creatorName }} left a comment on your Pony.fm profile!
Visit your profile with the following link to read it and reply:
{{ $notificationUrl }}
@endsection

View file

@ -0,0 +1,9 @@
@extends('emails.notifications._layout')
@section('content')
<p>
Congrats!
<a href="{{ $notificationUrl }}" target="_blank">{{ $creatorName }}</a>
is now following you on Pony.fm!
</p>
@endsection

View file

@ -0,0 +1,8 @@
@extends('emails.notifications._layout_plaintext')
@section('content')
Congrats! {{ $creatorName }} is now following you on Pony.fm!
Here's a link to their profile:
{{ $notificationUrl }}
@endsection

View file

@ -0,0 +1,8 @@
@extends('emails.notifications._layout')
@section('content')
<p>{{ $creatorName }} created a new playlist on Pony.fm! Check it out:</p>
<p><a href="{{ $notificationUrl }}" target="_blank">{{ $playlistTitle }}</a></p>
@endsection

View file

@ -0,0 +1,10 @@
@extends('emails.notifications._layout_plaintext')
@section('content')
{{ $creatorName }} created a new playlist on Pony.fm!
Title: {{ $playlistTitle }}
Listen to it:
{{ $notificationUrl }}
@endsection

View file

@ -0,0 +1,8 @@
@extends('emails.notifications._layout')
@section('content')
<p>{{ $creatorName }} published a new track on Pony.fm! Listen to it now:</p>
<p><a href="{{ $notificationUrl }}" target="_blank">{{ $trackTitle }}</a></p>
@endsection

View file

@ -0,0 +1,10 @@
@extends('emails.notifications._layout_plaintext')
@section('content')
{{ $creatorName }} published a new track on Pony.fm!
Title: {{ $trackTitle }}
Listen to it:
{{ $notificationUrl }}
@endsection

View file

@ -77,6 +77,11 @@ Route::get('p{id}/dl.{extension}', 'PlaylistsController@getDownload');
Route::get('notifications', 'AccountController@getNotifications');
Route::get('notifications/email/unsubscribe/{subscriptionKey}', 'NotificationsController@getEmailUnsubscribe')->name('email:unsubscribe');
Route::get('notifications/email/click/{emailKey}', 'NotificationsController@getEmailClick')->name('email:click');
Route::get('oembed', 'TracksController@getOembed');
Route::group(['prefix' => 'api/v1', 'middleware' => 'json-exceptions'], function () {