Feature/notifications (#87)

* #25: Implemented enough of the notification system to start writing drivers.

* #25: Implemented most of the Pony.fm notification driver's backend.

* #25: Abstracted the logic for building lists of notification recipients.

* #25: Implemented notification API endpoints for the SPA.

* Front end setup for notifications

* #25: Implemented notification API endpoints for the SPA.
This commit is contained in:
Peter Deltchev 2016-05-27 12:12:40 -07:00 committed by Josef Citrine
parent 7786950990
commit 0109244115
36 changed files with 1480 additions and 15 deletions

View file

@ -25,6 +25,8 @@ For quick fixes, go ahead and submit a pull request!
For larger features, it's best to open an issue before sinking a ton of work For larger features, it's best to open an issue before sinking a ton of work
into building them, to coordinate with Pony.fm's maintainers. into building them, to coordinate with Pony.fm's maintainers.
Developer documentation is available in the [`documentation` directory](documentation).
**Protip:** Looking for a place to jump in and start coding? Try a **Protip:** Looking for a place to jump in and start coding? Try a
[quickwin issue](https://github.com/Poniverse/Pony.fm/labels/quickwin%21) - [quickwin issue](https://github.com/Poniverse/Pony.fm/labels/quickwin%21) -
these are smaller in scope and easier to tackle if you're unfamiliar with the codebase! these are smaller in scope and easier to tackle if you're unfamiliar with the codebase!

View file

@ -20,6 +20,7 @@
namespace Poniverse\Ponyfm\Commands; namespace Poniverse\Ponyfm\Commands;
use Notification;
use Poniverse\Ponyfm\Models\Album; use Poniverse\Ponyfm\Models\Album;
use Poniverse\Ponyfm\Models\Comment; use Poniverse\Ponyfm\Models\Comment;
use Poniverse\Ponyfm\Models\Playlist; use Poniverse\Ponyfm\Models\Playlist;
@ -115,6 +116,8 @@ class CreateCommentCommand extends CommandBase
$entity->comment_count = Comment::where($column, $this->_id)->count(); $entity->comment_count = Comment::where($column, $this->_id)->count();
$entity->save(); $entity->save();
Notification::newComment($comment);
return CommandResponse::succeed(Comment::mapPublic($comment)); return CommandResponse::succeed(Comment::mapPublic($comment));
} }

View file

@ -20,6 +20,7 @@
namespace Poniverse\Ponyfm\Commands; namespace Poniverse\Ponyfm\Commands;
use Notification;
use Poniverse\Ponyfm\Models\Playlist; use Poniverse\Ponyfm\Models\Playlist;
use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Validator; use Illuminate\Support\Facades\Validator;
@ -68,6 +69,8 @@ class CreatePlaylistCommand extends CommandBase
$playlist->is_public = $this->_input['is_public'] == 'true'; $playlist->is_public = $this->_input['is_public'] == 'true';
$playlist->save(); $playlist->save();
Notification::publishedNewPlaylist($playlist);
if ($this->_input['is_pinned'] == 'true') { if ($this->_input['is_pinned'] == 'true') {
$playlist->pin(Auth::user()->id); $playlist->pin(Auth::user()->id);

View file

@ -21,6 +21,7 @@
namespace Poniverse\Ponyfm\Commands; namespace Poniverse\Ponyfm\Commands;
use Gate; use Gate;
use Notification;
use Poniverse\Ponyfm\Models\Album; use Poniverse\Ponyfm\Models\Album;
use Poniverse\Ponyfm\Models\Image; use Poniverse\Ponyfm\Models\Image;
use Poniverse\Ponyfm\Models\Track; use Poniverse\Ponyfm\Models\Track;
@ -32,6 +33,10 @@ use DB;
class EditTrackCommand extends CommandBase class EditTrackCommand extends CommandBase
{ {
private $_trackId; private $_trackId;
/**
* @var Track
*/
private $_track; private $_track;
private $_input; private $_input;
@ -132,6 +137,8 @@ class EditTrackCommand extends CommandBase
DB::table('tracks')->whereUserId($track->user_id)->update(['is_latest' => false]); DB::table('tracks')->whereUserId($track->user_id)->update(['is_latest' => false]);
$track->is_latest = true; $track->is_latest = true;
Notification::publishedNewTrack($track);
} }
if (isset($this->_input['cover_id'])) { if (isset($this->_input['cover_id'])) {
@ -174,12 +181,13 @@ class EditTrackCommand extends CommandBase
return CommandResponse::succeed(['real_cover_url' => $track->getCoverUrl(Image::NORMAL)]); return CommandResponse::succeed(['real_cover_url' => $track->getCoverUrl(Image::NORMAL)]);
} }
private function removeTrackFromAlbum($track) private function removeTrackFromAlbum(Track $track)
{ {
$album = $track->album; $album = $track->album;
$index = 0; $index = 0;
foreach ($album->tracks as $track) { foreach ($album->tracks as $track) {
/** @var $track Track */
if ($track->id == $this->_trackId) { if ($track->id == $this->_trackId) {
continue; continue;
} }

View file

@ -20,10 +20,14 @@
namespace Poniverse\Ponyfm\Commands; namespace Poniverse\Ponyfm\Commands;
use Notification;
use Poniverse\Ponyfm\Contracts\Favouritable;
use Poniverse\Ponyfm\Models\Favourite; use Poniverse\Ponyfm\Models\Favourite;
use Poniverse\Ponyfm\Models\Playlist;
use Poniverse\Ponyfm\Models\ResourceUser; use Poniverse\Ponyfm\Models\ResourceUser;
use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use Poniverse\Ponyfm\Models\Track;
class ToggleFavouriteCommand extends CommandBase class ToggleFavouriteCommand extends CommandBase
{ {
@ -45,6 +49,20 @@ class ToggleFavouriteCommand extends CommandBase
return $user != null; return $user != null;
} }
private function getEntityBeingFavourited():Favouritable
{
switch ($this->_resourceType) {
case 'track':
return Track::find($this->_resourceId);
case 'album':
return Album::find($this->_resourceId);
case 'playlist':
return Playlist::find($this->_resourceId);
default:
throw new \InvalidArgumentException('Unknown resource type given!');
}
}
/** /**
* @throws \Exception * @throws \Exception
@ -65,6 +83,8 @@ class ToggleFavouriteCommand extends CommandBase
$fav->created_at = time(); $fav->created_at = time();
$fav->save(); $fav->save();
$isFavourited = true; $isFavourited = true;
Notification::newFavourite($this->getEntityBeingFavourited(), $fav->user);
} }
$resourceUser = ResourceUser::get(Auth::user()->id, $this->_resourceType, $this->_resourceId); $resourceUser = ResourceUser::get(Auth::user()->id, $this->_resourceType, $this->_resourceId);

View file

@ -22,7 +22,8 @@ namespace Poniverse\Ponyfm\Commands;
use Poniverse\Ponyfm\Models\Follower; use Poniverse\Ponyfm\Models\Follower;
use Poniverse\Ponyfm\Models\ResourceUser; use Poniverse\Ponyfm\Models\ResourceUser;
use Illuminate\Support\Facades\Auth; use Auth;
use Notification;
class ToggleFollowingCommand extends CommandBase class ToggleFollowingCommand extends CommandBase
{ {
@ -64,6 +65,8 @@ class ToggleFollowingCommand extends CommandBase
$follow->created_at = time(); $follow->created_at = time();
$follow->save(); $follow->save();
$isFollowed = true; $isFollowed = true;
Notification::newFollower($follow->artist, Auth::user());
} }
$resourceUser = ResourceUser::get(Auth::user()->id, $this->_resourceType, $this->_resourceId); $resourceUser = ResourceUser::get(Auth::user()->id, $this->_resourceType, $this->_resourceId);

View file

@ -0,0 +1,38 @@
<?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\Contracts;
use Illuminate\Database\Eloquent\Relations\HasMany;
/**
* This interface is used for type safety when referring to entities that
* are capable of accepting comments.
*
* @package Poniverse\Ponyfm\Contracts
*/
interface Commentable extends GeneratesNotifications {
/**
* This method returns an Eloquent relation to the entity's comments.
*
* @return HasMany
*/
public function comments():HasMany;
}

View file

@ -0,0 +1,38 @@
<?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\Contracts;
use Illuminate\Database\Eloquent\Relations\HasMany;
/**
* This interface is used for type safety when referring to entities that
* are capable of being favourited.
*
* @package Poniverse\Ponyfm\Contracts
*/
interface Favouritable extends GeneratesNotifications {
/**
* This method returns an Eloquent relation to the entity's favourites.
*
* @return HasMany
*/
public function favourites():HasMany;
}

View file

@ -0,0 +1,42 @@
<?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\Contracts;
use Illuminate\Database\Eloquent\Relations\MorphMany;
use Poniverse\Ponyfm\Models\User;
/**
* This interface is used for type safety when referring to entities that can be
* the "target resource" of a notification (ie. what the notification is about).
*
* @package Poniverse\Ponyfm\Contracts
*/
interface GeneratesNotifications {
/**
* Returns a human-friendly string (lowercase & singular) representing this
* type of resource.
*
* @return string
*/
public function getResourceType():string;
public function activities():MorphMany;
}

View file

@ -0,0 +1,68 @@
<?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\Contracts;
use Poniverse\Ponyfm\Models\Comment;
use Poniverse\Ponyfm\Models\Playlist;
use Poniverse\Ponyfm\Models\Track;
use Poniverse\Ponyfm\Models\User;
/**
* Interface NotificationHandler
* @package Poniverse\Ponyfm\Contracts
*
* Each method in this interface represents a type of notification. To add a new
* type of notification, add a method for it to this interface and every class
* that implements it. Your IDE should be able to help with this.
*/
interface NotificationHandler {
/**
* @param Track $track
* @return void
*/
public function publishedNewTrack(Track $track);
/**
* @param Playlist $playlist
* @return void
*/
public function publishedNewPlaylist(Playlist $playlist);
/**
* @param User $userBeingFollowed
* @param User $follower
* @return void
*/
public function newFollower(User $userBeingFollowed, User $follower);
/**
* @param Comment $comment
* @return void
*/
public function newComment(Comment $comment);
/**
* @param Favouritable $entityBeingFavourited
* @param User $favouriter
* @return void
*/
public function newFavourite(Favouritable $entityBeingFavourited, User $favouriter);
}

View file

@ -0,0 +1,28 @@
<?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\Facades;
use Illuminate\Support\Facades\Facade;
class Notification extends Facade {
protected static function getFacadeAccessor() {
return 'notification';
}
}

View file

@ -20,10 +20,10 @@
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 Cover;
use Gate; use Gate;
use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Input; use Illuminate\Support\Facades\Input;

View file

@ -0,0 +1,63 @@
<?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\Api\Web;
use Auth;
use Carbon\Carbon;
use Input;
use Poniverse\Ponyfm\Http\Controllers\ApiControllerBase;
use Poniverse\Ponyfm\Models\Notification;
class NotificationsController extends ApiControllerBase
{
/**
* Returns the logged-in user's last 20 notifications.
*
* @return array
*/
public function getNotifications()
{
$notifications = Notification::forUser(Auth::user())
->take(20)
->get();
return ['notifications' => $notifications->toArray()];
}
/**
* This action returns the number of notifications that were updated.
* Any notifications that were specified that don't belong to the logged-in
* user are ignored.
*
* @return array
*/
public function putMarkAsRead()
{
$notificationIds = Input::get('notification_ids');
$numberOfUpdatedRows = Auth::user()
->notifications()
->whereIn('id', $notificationIds)
->update(['is_read' => true]);
return ['notifications_updated' => $numberOfUpdatedRows];
}
}

View file

@ -134,6 +134,8 @@ Route::group(['prefix' => 'api/web'], function() {
Route::group(['middleware' => 'auth'], function() { Route::group(['middleware' => 'auth'], function() {
Route::get('/account/settings/{slug}', 'Api\Web\AccountController@getSettings'); Route::get('/account/settings/{slug}', 'Api\Web\AccountController@getSettings');
Route::get('/notifications', 'Api\Web\NotificationsController@getNotifications');
Route::put('/notifications/mark-as-read', 'Api\Web\NotificationsController@putMarkAsRead');
Route::get('/tracks/owned', 'Api\Web\TracksController@getOwned'); Route::get('/tracks/owned', 'Api\Web\TracksController@getOwned');
Route::get('/tracks/edit/{id}', 'Api\Web\TracksController@getEdit'); Route::get('/tracks/edit/{id}', 'Api\Web\TracksController@getEdit');

View file

@ -0,0 +1,84 @@
<?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\Jobs;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Queue\InteractsWithQueue;
use Poniverse\Ponyfm\Jobs\Job;
use Illuminate\Contracts\Bus\SelfHandling;
use Poniverse\Ponyfm\Library\Notifications\Drivers\AbstractDriver;
use Poniverse\Ponyfm\Library\Notifications\Drivers\PonyfmDriver;
use Poniverse\Ponyfm\Models\User;
use SerializesModels;
class SendNotifications extends Job implements SelfHandling, ShouldQueue
{
use InteractsWithQueue, SerializesModels;
protected $notificationType;
protected $notificationData;
/**
* Create a new job instance.
* @param string $notificationType
* @param array $notificationData
*/
public function __construct(string $notificationType, array $notificationData)
{
$this->notificationType = $notificationType;
$this->notificationData = $notificationData;
}
/**
* Execute the job.
*
* @return void
*/
public function handle()
{
$this->beforeHandle();
// get list of users who are supposed to receive this notification
$recipients = [User::find(1)];
foreach ($recipients as $recipient) {
// get drivers that this notification should be delivered through
$drivers = $this->getDriversForNotification($recipient, $this->notificationType);
foreach ($drivers as $driver) {
/** @var $driver AbstractDriver */
call_user_func_array([$driver, $this->notificationType], $this->notificationData);
}
}
}
/**
* Returns the drivers with which the given user has subscribed to the given
* notification type.
*
* @param User $user
* @param string $notificationType
* @return AbstractDriver[]
*/
private function getDriversForNotification(User $user, string $notificationType) {
return [new PonyfmDriver()];
}
}

View file

@ -0,0 +1,47 @@
<?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\Library\Notifications\Drivers;
use ArrayAccess;
use Poniverse\Ponyfm\Contracts\NotificationHandler;
use Poniverse\Ponyfm\Library\Notifications\RecipientFinder;
use Poniverse\Ponyfm\Models\User;
abstract class AbstractDriver implements NotificationHandler {
private $recipientFinder;
public function __construct() {
$this->recipientFinder = new RecipientFinder(get_class($this));
}
/**
* Returns an array of users who are to receive the given notification type.
* This method is a wrapper around the {@link RecipientFinder} class, which
* does the actual processing for all the drivers.
*
* @param string $notificationType
* @param array $notificationData
* @return User[] collection of {@link User} objects
*/
protected function getRecipients(string $notificationType, array $notificationData) {
return call_user_func_array([$this->recipientFinder, $notificationType], $notificationData);
}
}

View file

@ -0,0 +1,123 @@
<?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\Library\Notifications\Drivers;
use ArrayAccess;
use Carbon\Carbon;
use Poniverse\Ponyfm\Contracts\Favouritable;
use Poniverse\Ponyfm\Models\Activity;
use Poniverse\Ponyfm\Models\Comment;
use Poniverse\Ponyfm\Models\Notification;
use Poniverse\Ponyfm\Models\Playlist;
use Poniverse\Ponyfm\Models\Track;
use Poniverse\Ponyfm\Models\User;
class PonyfmDriver extends AbstractDriver {
/**
* A helper method for bulk insertion of notification records.
*
* @param int $activityId
* @param User[] $recipients collection of {@link User} objects
*/
private function insertNotifications(int $activityId, $recipients) {
$notifications = [];
foreach ($recipients as $recipient) {
$notifications[] = [
'activity_id' => $activityId,
'user_id' => $recipient->id
];
}
Notification::insert($notifications);
}
/**
* @inheritdoc
*/
public function publishedNewTrack(Track $track) {
$activity = Activity::create([
'created_at' => Carbon::now(),
'user_id' => $track->user_id,
'activity_type' => Activity::TYPE_PUBLISHED_TRACK,
'resource_type' => Track::class,
'resource_id' => $track->id,
]);
$this->insertNotifications($activity->id, $this->getRecipients(__FUNCTION__, func_get_args()));
}
/**
* @inheritdoc
*/
public function publishedNewPlaylist(Playlist $playlist) {
$activity = Activity::create([
'created_at' => Carbon::now(),
'user_id' => $playlist->user_id,
'activity_type' => Activity::TYPE_PUBLISHED_PLAYLIST,
'resource_type' => Playlist::class,
'resource_id' => $playlist->id,
]);
$this->insertNotifications($activity->id, $this->getRecipients(__FUNCTION__, func_get_args()));
}
public function newFollower(User $userBeingFollowed, User $follower) {
$activity = Activity::create([
'created_at' => Carbon::now(),
'user_id' => $follower->id,
'activity_type' => Activity::TYPE_NEW_FOLLOWER,
'resource_type' => User::class,
'resource_id' => $userBeingFollowed->id,
]);
$this->insertNotifications($activity->id, $this->getRecipients(__FUNCTION__, func_get_args()));
}
/**
* @inheritdoc
*/
public function newComment(Comment $comment) {
$activity = Activity::create([
'created_at' => Carbon::now(),
'user_id' => $comment->user_id,
'activity_type' => Activity::TYPE_NEW_COMMENT,
'resource_type' => Comment::class,
'resource_id' => $comment->id,
]);
$this->insertNotifications($activity->id, $this->getRecipients(__FUNCTION__, func_get_args()));
}
/**
* @inheritdoc
*/
public function newFavourite(Favouritable $entityBeingFavourited, User $favouriter) {
$activity = Activity::create([
'created_at' => Carbon::now(),
'user_id' => $favouriter->id,
'activity_type' => Activity::TYPE_CONTENT_FAVOURITED,
'resource_type' => get_class($entityBeingFavourited),
'resource_id' => $entityBeingFavourited->id,
]);
$this->insertNotifications($activity->id, $this->getRecipients(__FUNCTION__, func_get_args()));
}
}

View file

@ -0,0 +1,83 @@
<?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\Library\Notifications;
use Illuminate\Foundation\Bus\DispatchesJobs;
use Poniverse\Ponyfm\Contracts\Favouritable;
use Poniverse\Ponyfm\Contracts\NotificationHandler;
use Poniverse\Ponyfm\Jobs\SendNotifications;
use Poniverse\Ponyfm\Models\Comment;
use Poniverse\Ponyfm\Models\Playlist;
use Poniverse\Ponyfm\Models\Track;
use Poniverse\Ponyfm\Models\User;
/**
* Class NotificationManager
* @package Poniverse\Ponyfm\Library
*
* This class exists mostly to maintain type safety when sending notifications
* from around the Pony.fm codebase. There should be virtually zero logic here.
* All the heavy lifting happens asynchronously in the {@link SendNotifications}
* job and the notification drivers.
*/
class NotificationManager implements NotificationHandler {
use DispatchesJobs;
private function dispatchNotification(string $notificationType, array $notificationData) {
$this->dispatch( (new SendNotifications($notificationType, $notificationData))->onQueue('notifications') );
}
/**
* @inheritdoc
*/
public function publishedNewTrack(Track $track) {
$this->dispatchNotification(__FUNCTION__, func_get_args());
}
/**
* @inheritdoc
*/
public function publishedNewPlaylist(Playlist $playlist) {
$this->dispatchNotification(__FUNCTION__, func_get_args());
}
/**
* @inheritdoc
*/
public function newFollower(User $userBeingFollowed, User $follower) {
$this->dispatchNotification(__FUNCTION__, func_get_args());
}
/**
* @inheritdoc
*/
public function newComment(Comment $comment) {
$this->dispatchNotification(__FUNCTION__, func_get_args());
}
/**
* @inheritdoc
*/
public function newFavourite(Favouritable $entityBeingFavourited, User $favouriter) {
$this->dispatchNotification(__FUNCTION__, func_get_args());
}
}

View file

@ -0,0 +1,120 @@
<?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\Library\Notifications;
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\PonyfmDriver;
use Poniverse\Ponyfm\Models\Comment;
use Poniverse\Ponyfm\Models\Playlist;
use Poniverse\Ponyfm\Models\Track;
use Poniverse\Ponyfm\Models\User;
/**
* Class RecipientFinder
* @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.
*/
class RecipientFinder implements NotificationHandler {
/**
* @var string class name of a notification driver
*/
private $notificationDriver;
public function __construct(string $notificationDriver) {
$this->notificationDriver = $notificationDriver;
}
private function fail() {
throw new \InvalidArgumentException("Unknown notification driver given: {$this->notificationDriver}");
}
/**
* @inheritdoc
*/
public function publishedNewTrack(Track $track) {
switch ($this->notificationDriver) {
case PonyfmDriver::class:
return $track->user->followers;
default:
return $this->fail();
}
}
/**
* @inheritdoc
*/
public function publishedNewPlaylist(Playlist $playlist) {
switch ($this->notificationDriver) {
case PonyfmDriver::class:
return $playlist->user->followers;
default:
return $this->fail();
}
}
/**
* @inheritdoc
*/
public function newFollower(User $userBeingFollowed, User $follower) {
switch ($this->notificationDriver) {
case PonyfmDriver::class:
return [$userBeingFollowed];
default:
return $this->fail();
}
}
/**
* @inheritdoc
*/
public function newComment(Comment $comment) {
switch ($this->notificationDriver) {
case PonyfmDriver::class:
return
$comment->user->id === $comment->resource->user->id
? []
: [$comment->resource->user];
default:
return $this->fail();
}
}
/**
* @inheritdoc
*/
public function newFavourite(Favouritable $entityBeingFavourited, User $favouriter) {
switch ($this->notificationDriver) {
case PonyfmDriver::class:
return
$favouriter->id === $entityBeingFavourited->user->id
? []
: [$entityBeingFavourited->user];
default:
return $this->fail();
}
}
}

205
app/Models/Activity.php Normal file
View file

@ -0,0 +1,205 @@
<?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 Illuminate\Database\Eloquent\Model;
/**
* Poniverse\Ponyfm\Models\Activity
*
* @property integer $id
* @property \Carbon\Carbon $created_at
* @property integer $user_id
* @property boolean $activity_type
* @property boolean $resource_type
* @property integer $resource_id
* @property-read \Illuminate\Database\Eloquent\Collection|\Poniverse\Ponyfm\Models\Notification[] $notifications
* @property-read \Poniverse\Ponyfm\Models\User $initiatingUser
* @property-read \Poniverse\Ponyfm\Models\Activity $resource
* @property-read mixed $url
* @property-read mixed $thumbnail_url
* @property-read mixed $text
*/
class Activity extends Model {
public $timestamps = false;
protected $dates = ['created_at'];
protected $fillable = ['created_at', 'user_id', 'activity_type', 'resource_type', 'resource_id'];
protected $appends = ['url', 'thumbnail_url', 'human_friendly_resource_type'];
protected $casts = [
'id' => 'integer',
'created_at' => 'datetime',
'user_id' => 'integer',
'activity_type' => 'integer',
// resource_type has its own accessor and mutator
'resource_id' => 'integer',
];
const TYPE_NEWS = 1;
const TYPE_PUBLISHED_TRACK = 2;
const TYPE_PUBLISHED_ALBUM = 3;
const TYPE_PUBLISHED_PLAYLIST = 4;
const TYPE_NEW_FOLLOWER = 5;
const TYPE_NEW_COMMENT = 6;
const TYPE_CONTENT_FAVOURITED = 7;
/**
* These "target" constants are an implementation detail of this model and
* should not be used directly in other classes. They're used to efficiently
* store the type of resource this notification is about in the database.
*
* The "resource_type" attribute is transformed into a class name at runtime
* so that the use of an integer in the database to represent this info
* remains an implementation detail of this model. Outside of this class,
* the resource_type attribute should be treated as a fully-qualified class
* name.
*/
const TARGET_USER = 1;
const TARGET_TRACK = 2;
const TARGET_ALBUM = 3;
const TARGET_PLAYLIST = 4;
const TARGET_COMMENT = 5;
public function initiatingUser() {
return $this->belongsTo(User::class, 'user_id', 'id');
}
public function notifications() {
return $this->hasMany(Notification::class, 'activity_id', 'id');
}
public function notificationRecipients() {
return $this->hasManyThrough(User::class, Notification::class, 'activity_id', 'user_id', 'id');
}
public function resource() {
return $this->morphTo('resource', 'resource_type', 'resource_id');
}
public function getUrlAttribute() {
return $this->resource->url;
}
public function getResourceTypeAttribute($value) {
switch ($value) {
case static::TARGET_USER:
return User::class;
case static::TARGET_TRACK:
return Track::class;
case static::TARGET_ALBUM:
return Album::class;
case static::TARGET_PLAYLIST:
return Playlist::class;
case static::TARGET_COMMENT:
return Comment::class;
default:
// Null must be returned here for Eloquent's eager-loading
// of the polymorphic relation to work.
return NULL;
}
}
public function setResourceTypeAttribute($value) {
switch ($value) {
case User::class:
$this->attributes['resource_type'] = static::TARGET_USER;
break;
case Track::class:
$this->attributes['resource_type'] = static::TARGET_TRACK;
break;
case Album::class:
$this->attributes['resource_type'] = static::TARGET_ALBUM;
break;
case Playlist::class:
$this->attributes['resource_type'] = static::TARGET_PLAYLIST;
break;
case Comment::class:
$this->attributes['resource_type'] = static::TARGET_COMMENT;
break;
}
}
public function getThumbnailUrlAttribute()
{
switch ($this->resource_type) {
case User::class:
return $this->resource->getAvatarUrl(Image::THUMBNAIL);
case Track::class:
case Album::class:
case Playlist::class:
return $this->resource->getCoverUrl(Image::THUMBNAIL);
case Comment::class:
return $this->resource->user->getAvatarUrl(Image::THUMBNAIL);
default:
throw new \Exception('This activity\'s resource is of an unknown type!');
}
}
/**
* @return string human-readable Markdown string describing this notification
* @throws \Exception
*/
public function getTextAttribute()
{
switch ($this->activity_type) {
case static::TYPE_NEWS:
// not implemented yet
throw new \InvalidArgumentException('This type of activity has not been implemented yet!');
case static::TYPE_PUBLISHED_TRACK:
return "{$this->resource->user->display_name} published a new track, __{$this->resource->title}__!";
case static::TYPE_PUBLISHED_PLAYLIST:
return "{$this->resource->user->display_name} published a new playlist, __{$this->resource->title}__!";
case static::TYPE_NEW_FOLLOWER:
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) {
return "{$this->initiatingUser->display_name} left a comment on your profile!";
// Must be a content comment.
} else {
return "{$this->initiatingUser->display_name} left a comment on your {$this->resource->resource->getResourceType()}, __{$this->resource->resource->title}__!";
}
case static::TYPE_CONTENT_FAVOURITED:
return "{$this->initiatingUser->display_name} favourited your {$this->resource->type}, __{$this->resource->title}__!";
default:
throw new \Exception('This activity\'s activity type is unknown!');
}
}
}

View file

@ -23,10 +23,14 @@ namespace Poniverse\Ponyfm\Models;
use Exception; use Exception;
use Helpers; use Helpers;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\MorphMany;
use Illuminate\Database\Eloquent\SoftDeletes; use Illuminate\Database\Eloquent\SoftDeletes;
use Auth; use Auth;
use Gate; use Gate;
use Cache; use Cache;
use Poniverse\Ponyfm\Contracts\Commentable;
use Poniverse\Ponyfm\Contracts\Favouritable;
use Poniverse\Ponyfm\Contracts\Searchable; use Poniverse\Ponyfm\Contracts\Searchable;
use Poniverse\Ponyfm\Exceptions\TrackFileNotFoundException; use Poniverse\Ponyfm\Exceptions\TrackFileNotFoundException;
use Poniverse\Ponyfm\Traits\IndexedInElasticsearchTrait; use Poniverse\Ponyfm\Traits\IndexedInElasticsearchTrait;
@ -60,8 +64,9 @@ use Venturecraft\Revisionable\RevisionableTrait;
* @property-read mixed $url * @property-read mixed $url
* @property-read \Illuminate\Database\Eloquent\Collection|\Venturecraft\Revisionable\Revision[] $revisionHistory * @property-read \Illuminate\Database\Eloquent\Collection|\Venturecraft\Revisionable\Revision[] $revisionHistory
* @method static \Illuminate\Database\Query\Builder|\Poniverse\Ponyfm\Models\Album userDetails() * @method static \Illuminate\Database\Query\Builder|\Poniverse\Ponyfm\Models\Album userDetails()
* @property-read \Illuminate\Database\Eloquent\Collection|\Poniverse\Ponyfm\Models\Activity[] $activities
*/ */
class Album extends Model implements Searchable class Album extends Model implements Searchable, Commentable, Favouritable
{ {
use SoftDeletes, SlugTrait, TrackCollection, RevisionableTrait, IndexedInElasticsearchTrait; use SoftDeletes, SlugTrait, TrackCollection, RevisionableTrait, IndexedInElasticsearchTrait;
@ -101,7 +106,7 @@ class Album extends Model implements Searchable
return $this->hasMany('Poniverse\Ponyfm\Models\ResourceUser'); return $this->hasMany('Poniverse\Ponyfm\Models\ResourceUser');
} }
public function favourites() public function favourites():HasMany
{ {
return $this->hasMany('Poniverse\Ponyfm\Models\Favourite'); return $this->hasMany('Poniverse\Ponyfm\Models\Favourite');
} }
@ -120,11 +125,15 @@ class Album extends Model implements Searchable
return $this->hasManyThrough(TrackFile::class, Track::class, 'album_id', 'track_id'); return $this->hasManyThrough(TrackFile::class, Track::class, 'album_id', 'track_id');
} }
public function comments() public function comments():HasMany
{ {
return $this->hasMany('Poniverse\Ponyfm\Models\Comment')->orderBy('created_at', 'desc'); return $this->hasMany('Poniverse\Ponyfm\Models\Comment')->orderBy('created_at', 'desc');
} }
public function activities():MorphMany {
return $this->morphMany(Activity::class, 'resource');
}
public static function mapPublicAlbumShow(Album $album) public static function mapPublicAlbumShow(Album $album)
{ {
$tracks = []; $tracks = [];
@ -429,4 +438,14 @@ class Album extends Model implements Searchable
public function shouldBeIndexed():bool { public function shouldBeIndexed():bool {
return $this->track_count > 0 && !$this->trashed(); return $this->track_count > 0 && !$this->trashed();
} }
/**
* Returns the corresponding resource type ID from the Activity class for
* this resource.
*
* @return string
*/
public function getResourceType():string {
return 'album';
}
} }

View file

@ -21,7 +21,10 @@
namespace Poniverse\Ponyfm\Models; namespace Poniverse\Ponyfm\Models;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\MorphMany;
use Illuminate\Database\Eloquent\SoftDeletes; use Illuminate\Database\Eloquent\SoftDeletes;
use Poniverse\Ponyfm\Contracts\Commentable;
use Poniverse\Ponyfm\Contracts\GeneratesNotifications;
/** /**
* Poniverse\Ponyfm\Models\Comment * Poniverse\Ponyfm\Models\Comment
@ -42,7 +45,9 @@ use Illuminate\Database\Eloquent\SoftDeletes;
* @property-read \Poniverse\Ponyfm\Models\Album $album * @property-read \Poniverse\Ponyfm\Models\Album $album
* @property-read \Poniverse\Ponyfm\Models\Playlist $playlist * @property-read \Poniverse\Ponyfm\Models\Playlist $playlist
* @property-read \Poniverse\Ponyfm\Models\User $profile * @property-read \Poniverse\Ponyfm\Models\User $profile
* @property-read mixed $resource * @property-read Commentable $resource
* @property-read \Illuminate\Database\Eloquent\Collection|\Poniverse\Ponyfm\Models\Activity[] $activities
* @property-read mixed $url
*/ */
class Comment extends Model class Comment extends Model
{ {
@ -78,6 +83,16 @@ class Comment extends Model
return $this->belongsTo('Poniverse\Ponyfm\Models\User', 'profile_id'); return $this->belongsTo('Poniverse\Ponyfm\Models\User', 'profile_id');
} }
public function activities():MorphMany
{
return $this->morphMany(Activity::class, 'resource');
}
public function getUrlAttribute()
{
return $this->resource->url;
}
public static function mapPublic($comment) public static function mapPublic($comment)
{ {
return [ return [
@ -97,7 +112,7 @@ class Comment extends Model
]; ];
} }
public function getResourceAttribute() public function getResourceAttribute():Commentable
{ {
if ($this->track_id !== null) { if ($this->track_id !== null) {
return $this->track; return $this->track;

View file

@ -21,6 +21,7 @@
namespace Poniverse\Ponyfm\Models; namespace Poniverse\Ponyfm\Models;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
/** /**
* Poniverse\Ponyfm\Models\Follower * Poniverse\Ponyfm\Models\Follower
@ -30,10 +31,21 @@ use Illuminate\Database\Eloquent\Model;
* @property integer $artist_id * @property integer $artist_id
* @property integer $playlist_id * @property integer $playlist_id
* @property string $created_at * @property string $created_at
* @property-read \Poniverse\Ponyfm\Models\User $follower
* @property-read \Poniverse\Ponyfm\Models\User $artist
*/ */
class Follower extends Model class Follower extends Model
{ {
protected $table = 'followers'; protected $table = 'followers';
public $timestamps = false; public $timestamps = false;
public function follower():BelongsTo {
return $this->belongsTo(User::class, 'user_id');
}
public function artist():BelongsTo {
return $this->belongsTo(User::class, 'artist_id');
}
} }

View file

@ -0,0 +1,83 @@
<?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 Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
/**
* Poniverse\Ponyfm\Models\Notification
*
* @property integer $id
* @property integer $activity_id
* @property integer $user_id
* @property boolean $is_read
* @property-read \Poniverse\Ponyfm\Models\Activity $activity
* @property-read \Poniverse\Ponyfm\Models\User $recipient
* @method static \Illuminate\Database\Query\Builder|\Poniverse\Ponyfm\Models\Notification forUser($user)
*/
class Notification extends Model {
public $timestamps = false;
protected $fillable = ['activity_id', 'user_id'];
protected $casts = [
'id' => 'integer',
'activity_id' => 'integer',
'user_id' => 'integer',
'is_read' => 'boolean',
];
public function activity() {
return $this->belongsTo(Activity::class, 'activity_id', 'id');
}
public function recipient() {
return $this->belongsTo(User::class, 'user_id', 'id');
}
/**
* This scope grabs eager-loaded notifications for the given user.
*
* @param Builder $query
* @param User $user
* @return Builder
*/
public function scopeForUser(Builder $query, User $user) {
return $query->with([
'activity',
'activity.initiatingUser',
'activity.resource',
'activity.resource.user',
])
->join('activities', 'notifications.activity_id', '=', 'activities.id')
->where('notifications.user_id', $user->id)
->orderBy('activities.created_at', 'DESC');
}
public function toArray() {
return [
'id' => $this->id,
'date' => $this->activity->created_at->toAtomString(),
'thumbnail_url' => $this->activity->thumbnail_url,
'text' => $this->activity->text,
'url' => $this->activity->url
];
}
}

View file

@ -22,10 +22,14 @@ namespace Poniverse\Ponyfm\Models;
use Helpers; use Helpers;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\MorphMany;
use Illuminate\Database\Eloquent\SoftDeletes; use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Foundation\Bus\DispatchesJobs; use Illuminate\Foundation\Bus\DispatchesJobs;
use Auth; use Auth;
use Cache; use Cache;
use Poniverse\Ponyfm\Contracts\Commentable;
use Poniverse\Ponyfm\Contracts\Favouritable;
use Poniverse\Ponyfm\Contracts\Searchable; use Poniverse\Ponyfm\Contracts\Searchable;
use Poniverse\Ponyfm\Exceptions\TrackFileNotFoundException; use Poniverse\Ponyfm\Exceptions\TrackFileNotFoundException;
use Poniverse\Ponyfm\Traits\IndexedInElasticsearchTrait; use Poniverse\Ponyfm\Traits\IndexedInElasticsearchTrait;
@ -59,8 +63,10 @@ use Venturecraft\Revisionable\RevisionableTrait;
* @property-read mixed $url * @property-read mixed $url
* @property-read \Illuminate\Database\Eloquent\Collection|\Venturecraft\Revisionable\Revision[] $revisionHistory * @property-read \Illuminate\Database\Eloquent\Collection|\Venturecraft\Revisionable\Revision[] $revisionHistory
* @method static \Illuminate\Database\Query\Builder|\Poniverse\Ponyfm\Models\Playlist userDetails() * @method static \Illuminate\Database\Query\Builder|\Poniverse\Ponyfm\Models\Playlist userDetails()
* @property-read \Illuminate\Database\Eloquent\Collection|\Poniverse\Ponyfm\Models\Favourite[] $favourites
* @property-read \Illuminate\Database\Eloquent\Collection|\Poniverse\Ponyfm\Models\Activity[] $activities
*/ */
class Playlist extends Model implements Searchable class Playlist extends Model implements Searchable, Commentable, Favouritable
{ {
use SoftDeletes, SlugTrait, TrackCollection, RevisionableTrait, IndexedInElasticsearchTrait; use SoftDeletes, SlugTrait, TrackCollection, RevisionableTrait, IndexedInElasticsearchTrait;
@ -212,7 +218,7 @@ class Playlist extends Model implements Searchable
return $this->hasMany('Poniverse\Ponyfm\Models\ResourceUser'); return $this->hasMany('Poniverse\Ponyfm\Models\ResourceUser');
} }
public function comments() public function comments():HasMany
{ {
return $this->hasMany('Poniverse\Ponyfm\Models\Comment')->orderBy('created_at', 'desc'); return $this->hasMany('Poniverse\Ponyfm\Models\Comment')->orderBy('created_at', 'desc');
} }
@ -221,12 +227,20 @@ class Playlist extends Model implements Searchable
{ {
return $this->hasMany('Poniverse\Ponyfm\Models\PinnedPlaylist'); return $this->hasMany('Poniverse\Ponyfm\Models\PinnedPlaylist');
} }
public function favourites():HasMany {
return $this->hasMany(Favourite::class);
}
public function user() public function user()
{ {
return $this->belongsTo('Poniverse\Ponyfm\Models\User'); return $this->belongsTo('Poniverse\Ponyfm\Models\User');
} }
public function activities():MorphMany {
return $this->morphMany(Activity::class, 'resource');
}
public function hasPinFor($userId) public function hasPinFor($userId)
{ {
foreach ($this->pins as $pin) { foreach ($this->pins as $pin) {
@ -324,4 +338,11 @@ class Playlist extends Model implements Searchable
$this->track_count > 0 && $this->track_count > 0 &&
!$this->trashed(); !$this->trashed();
} }
/**
* @inheritdoc
*/
public function getResourceType():string {
return 'playlist';
}
} }

View file

@ -25,6 +25,10 @@ use Cache;
use Config; use Config;
use DB; use DB;
use Gate; use Gate;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\MorphMany;
use Poniverse\Ponyfm\Contracts\Commentable;
use Poniverse\Ponyfm\Contracts\Favouritable;
use Poniverse\Ponyfm\Contracts\Searchable; use Poniverse\Ponyfm\Contracts\Searchable;
use Poniverse\Ponyfm\Exceptions\TrackFileNotFoundException; use Poniverse\Ponyfm\Exceptions\TrackFileNotFoundException;
use Poniverse\Ponyfm\Traits\IndexedInElasticsearchTrait; use Poniverse\Ponyfm\Traits\IndexedInElasticsearchTrait;
@ -95,8 +99,10 @@ use Venturecraft\Revisionable\RevisionableTrait;
* @method static \Illuminate\Database\Query\Builder|\Poniverse\Ponyfm\Models\Track explicitFilter() * @method static \Illuminate\Database\Query\Builder|\Poniverse\Ponyfm\Models\Track explicitFilter()
* @method static \Illuminate\Database\Query\Builder|\Poniverse\Ponyfm\Models\Track withComments() * @method static \Illuminate\Database\Query\Builder|\Poniverse\Ponyfm\Models\Track withComments()
* @method static \Illuminate\Database\Query\Builder|\Poniverse\Ponyfm\Models\Track mlpma() * @method static \Illuminate\Database\Query\Builder|\Poniverse\Ponyfm\Models\Track mlpma()
* @property-read \Illuminate\Database\Eloquent\Collection|\Poniverse\Ponyfm\Models\Activity[] $notifications
* @property-read \Illuminate\Database\Eloquent\Collection|\Poniverse\Ponyfm\Models\Activity[] $activities
*/ */
class Track extends Model implements Searchable class Track extends Model implements Searchable, Commentable, Favouritable
{ {
use SoftDeletes, IndexedInElasticsearchTrait; use SoftDeletes, IndexedInElasticsearchTrait;
@ -486,12 +492,12 @@ class Track extends Model implements Searchable
return $this->belongsTo('Poniverse\Ponyfm\Models\TrackType', 'track_type_id'); return $this->belongsTo('Poniverse\Ponyfm\Models\TrackType', 'track_type_id');
} }
public function comments() public function comments():HasMany
{ {
return $this->hasMany('Poniverse\Ponyfm\Models\Comment')->orderBy('created_at', 'desc'); return $this->hasMany('Poniverse\Ponyfm\Models\Comment')->orderBy('created_at', 'desc');
} }
public function favourites() public function favourites():HasMany
{ {
return $this->hasMany('Poniverse\Ponyfm\Models\Favourite'); return $this->hasMany('Poniverse\Ponyfm\Models\Favourite');
} }
@ -526,6 +532,15 @@ class Track extends Model implements Searchable
return $this->hasMany('Poniverse\Ponyfm\Models\TrackFile'); return $this->hasMany('Poniverse\Ponyfm\Models\TrackFile');
} }
public function notifications()
{
return $this->morphMany(Activity::class, 'notification_type');
}
public function activities():MorphMany {
return $this->morphMany(Activity::class, 'resource');
}
public function getYearAttribute() public function getYearAttribute()
{ {
return date('Y', strtotime($this->getReleaseDate())); return date('Y', strtotime($this->getReleaseDate()));
@ -863,4 +878,8 @@ class Track extends Model implements Searchable
'show_songs' => $this->showSongs->pluck('title') 'show_songs' => $this->showSongs->pluck('title')
]; ];
} }
public function getResourceType():string {
return 'track';
}
} }

View file

@ -26,9 +26,12 @@ use Illuminate\Auth\Passwords\CanResetPassword;
use Illuminate\Contracts\Auth\Authenticatable as AuthenticatableContract; use Illuminate\Contracts\Auth\Authenticatable as AuthenticatableContract;
use Illuminate\Contracts\Auth\CanResetPassword as CanResetPasswordContract; use Illuminate\Contracts\Auth\CanResetPassword as CanResetPasswordContract;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\MorphMany;
use Illuminate\Foundation\Auth\Access\Authorizable; use Illuminate\Foundation\Auth\Access\Authorizable;
use Auth; use Auth;
use Illuminate\Support\Str; use Illuminate\Support\Str;
use Poniverse\Ponyfm\Contracts\Commentable;
use Poniverse\Ponyfm\Contracts\Searchable; use Poniverse\Ponyfm\Contracts\Searchable;
use Poniverse\Ponyfm\Traits\IndexedInElasticsearchTrait; use Poniverse\Ponyfm\Traits\IndexedInElasticsearchTrait;
use Venturecraft\Revisionable\RevisionableTrait; use Venturecraft\Revisionable\RevisionableTrait;
@ -63,8 +66,12 @@ use Venturecraft\Revisionable\RevisionableTrait;
* @property-read mixed $message_url * @property-read mixed $message_url
* @property-read \Illuminate\Database\Eloquent\Collection|\Venturecraft\Revisionable\Revision[] $revisionHistory * @property-read \Illuminate\Database\Eloquent\Collection|\Venturecraft\Revisionable\Revision[] $revisionHistory
* @method static \Illuminate\Database\Query\Builder|\Poniverse\Ponyfm\Models\User userDetails() * @method static \Illuminate\Database\Query\Builder|\Poniverse\Ponyfm\Models\User userDetails()
* @property-read \Illuminate\Database\Eloquent\Collection|\Poniverse\Ponyfm\Models\Notification[] $notifications
* @property-read \Illuminate\Database\Eloquent\Collection|\Poniverse\Ponyfm\Models\User[] $followers
* @property-read mixed $user
* @property-read \Illuminate\Database\Eloquent\Collection|\Poniverse\Ponyfm\Models\Activity[] $activities
*/ */
class User extends Model implements AuthenticatableContract, CanResetPasswordContract, \Illuminate\Contracts\Auth\Access\Authorizable, Searchable class User extends Model implements AuthenticatableContract, CanResetPasswordContract, \Illuminate\Contracts\Auth\Access\Authorizable, Searchable, Commentable
{ {
use Authenticatable, CanResetPassword, Authorizable, RevisionableTrait, IndexedInElasticsearchTrait; use Authenticatable, CanResetPassword, Authorizable, RevisionableTrait, IndexedInElasticsearchTrait;
@ -133,13 +140,18 @@ class User extends Model implements AuthenticatableContract, CanResetPasswordCon
{ {
return $this->hasMany('Poniverse\Ponyfm\Models\ResourceUser', 'artist_id'); return $this->hasMany('Poniverse\Ponyfm\Models\ResourceUser', 'artist_id');
} }
public function followers()
{
return $this->belongsToMany(User::class, 'followers', 'artist_id', 'user_id');
}
public function roles() public function roles()
{ {
return $this->belongsToMany(Role::class, 'role_user'); return $this->belongsToMany(Role::class, 'role_user');
} }
public function comments() public function comments():HasMany
{ {
return $this->hasMany('Poniverse\Ponyfm\Models\Comment', 'profile_id')->orderBy('created_at', 'desc'); return $this->hasMany('Poniverse\Ponyfm\Models\Comment', 'profile_id')->orderBy('created_at', 'desc');
} }
@ -148,6 +160,16 @@ class User extends Model implements AuthenticatableContract, CanResetPasswordCon
{ {
return $this->hasMany('Poniverse\Ponyfm\Models\Track', 'user_id'); return $this->hasMany('Poniverse\Ponyfm\Models\Track', 'user_id');
} }
public function notifications()
{
return $this->hasMany(Notification::class, 'user_id');
}
public function notificationActivities()
{
return $this->hasManyThrough(Activity::class, Notification::class, 'user_id', 'notification_id', 'id');
}
public function getIsArchivedAttribute() public function getIsArchivedAttribute()
{ {
@ -235,6 +257,21 @@ class User extends Model implements AuthenticatableContract, CanResetPasswordCon
return "remember_token"; return "remember_token";
} }
public function getUserAttribute():User {
return $this;
}
/**
* @inheritdoc
*/
public function getResourceType():string {
return 'profile';
}
public function activities():MorphMany {
return $this->morphMany(Activity::class, 'resource');
}
/** /**
* Returns true if this user has the given role. * Returns true if this user has the given role.
* *

View file

@ -20,6 +20,7 @@
namespace Poniverse\Ponyfm\Providers; namespace Poniverse\Ponyfm\Providers;
use Illuminate\Database\Eloquent\Relations\Relation;
use Illuminate\Foundation\Application; use Illuminate\Foundation\Application;
use Illuminate\Support\ServiceProvider; use Illuminate\Support\ServiceProvider;
use PfmValidator; use PfmValidator;
@ -58,5 +59,16 @@ class AppServiceProvider extends ServiceProvider
$app['config']->get('ponyfm.elasticsearch_index') $app['config']->get('ponyfm.elasticsearch_index')
); );
}); });
// NOTE: Use integer keys exclusively for Pony.fm's morphMap to avoid
// any weirdness with merging array indices. $merge = false is
// set below so that no morphMap array merging happens!
Relation::morphMap([
Poniverse\Ponyfm\Models\Activity::TARGET_TRACK => Poniverse\Ponyfm\Models\Track::class,
Poniverse\Ponyfm\Models\Activity::TARGET_ALBUM => Poniverse\Ponyfm\Models\Album::class,
Poniverse\Ponyfm\Models\Activity::TARGET_PLAYLIST => Poniverse\Ponyfm\Models\Playlist::class,
Poniverse\Ponyfm\Models\Activity::TARGET_USER => Poniverse\Ponyfm\Models\User::class,
Poniverse\Ponyfm\Models\Activity::TARGET_COMMENT => Poniverse\Ponyfm\Models\Comment::class,
], false);
} }
} }

View file

@ -0,0 +1,50 @@
<?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\Providers;
use Illuminate\Foundation\Application;
use Illuminate\Support\ServiceProvider;
use Poniverse\Ponyfm\Library\Notifications\NotificationManager;
class NotificationServiceProvider extends ServiceProvider
{
/**
* Bootstrap the application services.
*
* @return void
*/
public function boot()
{
//
}
/**
* Register the application services.
*
* @return void
*/
public function register()
{
$this->app->singleton('notification', function(Application $app){
return new NotificationManager();
});
}
}

View file

@ -145,6 +145,7 @@ return [
Poniverse\Ponyfm\Providers\EventServiceProvider::class, Poniverse\Ponyfm\Providers\EventServiceProvider::class,
Poniverse\Ponyfm\Providers\RouteServiceProvider::class, Poniverse\Ponyfm\Providers\RouteServiceProvider::class,
Poniverse\Ponyfm\Providers\AuthServiceProvider::class, Poniverse\Ponyfm\Providers\AuthServiceProvider::class,
Poniverse\Ponyfm\Providers\NotificationServiceProvider::class,
Intouch\LaravelNewrelic\NewrelicServiceProvider::class, Intouch\LaravelNewrelic\NewrelicServiceProvider::class,
Barryvdh\LaravelIdeHelper\IdeHelperServiceProvider::class, Barryvdh\LaravelIdeHelper\IdeHelperServiceProvider::class,
@ -201,6 +202,7 @@ return [
'Elasticsearch' => Cviebrock\LaravelElasticsearch\Facade::class, 'Elasticsearch' => Cviebrock\LaravelElasticsearch\Facade::class,
'Newrelic' => Intouch\LaravelNewrelic\Facades\Newrelic::class, 'Newrelic' => Intouch\LaravelNewrelic\Facades\Newrelic::class,
'Notification' => Poniverse\Ponyfm\Facades\Notification::class,
], ],

View file

@ -0,0 +1,64 @@
<?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\Database\Schema\Blueprint;
use Illuminate\Database\Migrations\Migration;
class CreateNotificationsTables extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
Schema::create('activities', function(Blueprint $table){
$table->bigIncrements('id');
$table->dateTime('created_at')->index();
$table->unsignedInteger('user_id'); // initiator of the action
$table->unsignedTinyInteger('activity_type');
$table->unsignedTinyInteger('resource_type');
$table->unsignedInteger('resource_id'); // ID of the entity this activity is about
});
Schema::create('notifications', function(Blueprint $table){
// Notifications are a pivot table between activities and users.
$table->bigIncrements('id');
$table->unsignedBigInteger('activity_id')->index();
$table->unsignedInteger('user_id')->index(); // recipient of the notification
$table->boolean('is_read')->default(false)->index();
$table->foreign('activity_id')->references('id')->on('activities')->onDelete('cascade');
$table->foreign('user_id')->references('id')->on('users');
});
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
Schema::drop('notifications');
Schema::drop('activities');
}
}

View file

@ -0,0 +1,94 @@
Developing notifications for Pony.fm
====================================
Pony.fm's notification system is designed around "drivers" for various
notification delivery methods. The types of notification one can receive
are defined in the
[`NotificationHandler`](app/Contracts/NotificationHandler.php)
interface, which is implemented by every class that needs to know about
the various notification types.
Sending a notification
----------------------
The `Notification` facade is used to send notifications as follows:
```php
use Notification;
// Something happens, like a newtrack getting published.
$track = new Track();
...
// The "something" is done happening! Time to send a notification.
Notification::publishedTrack($track);
```
This facade has a method for every notification type, drawn from the
[`NotificationHandler`](../app/Contracts/NotificationHandler.php) interface.
Each of these methods accepts the data needed to build a notification
message and a list of the notification's recipients.
Adding new notification types
-----------------------------
1. Add a method for the new notification type to the
[`NotificationHandler`](../app/Contracts/NotificationHandler.php)
interface.
2. Implement the new methods in every class that implements the
interface. Use your IDE to find these. An inexhaustive list:
- [`NotificationManager`](../app/Library/Notifications/NotificationManager.php)
- [`RecipientFinder`](../app/Library/Notifications/RecipientFinder.php)
- [`PonyfmDriver`](../app/Library/Notifications/PonyfmDriver.php)
3. 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
[`Activity`](../app/Models/Activity.php) model.
Adding new notification drivers
-------------------------------
1. Create a new class for the driver that implements the
[`NotificationHandler`](../app/Contracts/NotificationHandler.php)
interface.
2. Make each method from the above interface send the corresponding type
of notification to everyone who is to receive it via that driver.
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
-------------------
The notification system is designed around two ideas: being as type-safe
as PHP allows it to be, and doing all the processing and sending of
notifications asynchronously.
To that end, the
[`NotificationManager`](../app/Library/Notifications/NotificationManager.php)
class is a thin wrapper around the `SendNotifications` job. The job
calls the notification drivers asynchronously to actually send the
notifications. This job should run on a dedicated queue in production.
The [`NotificationHandler`](../app/Contracts/NotificationHandler.php)
interface is key to maintaining type safety - it ensures that drivers
and `NotificationManager` all support every type of notification. All
classes that have logic specific to a notification type implement this
interface to ensure that all notification types are handled.
There's one exception to the use of `NotificationHandler` - the
[`Activity`](../app/Models/Activity.php) model. The logic for mapping 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.

View file

@ -285,6 +285,11 @@ ponyfm.config [
url: '/' url: '/'
templateUrl: '/templates/dashboard/index.html' templateUrl: '/templates/dashboard/index.html'
controller: 'dashboard' controller: 'dashboard'
state.state 'notifications',
url: '/notifications'
templateUrl: '/templates/notifications/index.html'
controller: 'notifications'
else else
state.state 'home', state.state 'home',
url: '/' url: '/'

View file

@ -0,0 +1,22 @@
# Pony.fm - A community for pony fan music.
# Copyright (C) 2016 Josef Citrine
#
# 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/>.
module.exports = angular.module('ponyfm').controller "notifications", [
'$scope', 'notifications'
($scope, notifications) ->
notifications.getNotifications().done (result) ->
console.log result
]

View file

@ -0,0 +1,30 @@
# Pony.fm - A community for pony fan music.
# Copyright (C) 2016 Josef Citrine
#
# 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/>.
module.exports = angular.module('ponyfm').factory('notifications', [
'$rootScope', '$http'
($rootScope, $http) ->
self =
getNotifications: () ->
def = new $.Deferred()
$http.get('/api/web/account/notifications').success (response) ->
def.resolve response
def.promise()
self
])