From 01092441150e1f22d9d21e52a887d2e2b0222dc4 Mon Sep 17 00:00:00 2001 From: Peter Deltchev Date: Fri, 27 May 2016 12:12:40 -0700 Subject: [PATCH] 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. --- README.md | 2 + app/Commands/CreateCommentCommand.php | 3 + app/Commands/CreatePlaylistCommand.php | 3 + app/Commands/EditTrackCommand.php | 10 +- app/Commands/ToggleFavouriteCommand.php | 20 ++ app/Commands/ToggleFollowingCommand.php | 5 +- app/Contracts/Commentable.php | 38 ++++ app/Contracts/Favouritable.php | 38 ++++ app/Contracts/GeneratesNotifications.php | 42 ++++ app/Contracts/NotificationHandler.php | 68 ++++++ app/Facades/Notification.php | 28 +++ .../Controllers/Api/Web/AccountController.php | 2 +- .../Api/Web/NotificationsController.php | 63 ++++++ app/Http/routes.php | 2 + app/Jobs/SendNotifications.php | 84 +++++++ .../Notifications/Drivers/AbstractDriver.php | 47 ++++ .../Notifications/Drivers/PonyfmDriver.php | 123 +++++++++++ .../Notifications/NotificationManager.php | 83 +++++++ app/Library/Notifications/RecipientFinder.php | 120 ++++++++++ app/Models/Activity.php | 205 ++++++++++++++++++ app/Models/Album.php | 25 ++- app/Models/Comment.php | 19 +- app/Models/Follower.php | 12 + app/Models/Notification.php | 83 +++++++ app/Models/Playlist.php | 25 ++- app/Models/Track.php | 25 ++- app/Models/User.php | 41 +++- app/Providers/AppServiceProvider.php | 12 + app/Providers/NotificationServiceProvider.php | 50 +++++ config/app.php | 2 + ..._06_152844_create_notifications_tables.php | 64 ++++++ documentation/notifications.md | 94 ++++++++ public/templates/notifications/index.html | 0 resources/assets/scripts/app/app.coffee | 5 + .../app/controllers/notifications.coffee | 22 ++ .../scripts/app/services/notifications.coffee | 30 +++ 36 files changed, 1480 insertions(+), 15 deletions(-) create mode 100644 app/Contracts/Commentable.php create mode 100644 app/Contracts/Favouritable.php create mode 100644 app/Contracts/GeneratesNotifications.php create mode 100644 app/Contracts/NotificationHandler.php create mode 100644 app/Facades/Notification.php create mode 100644 app/Http/Controllers/Api/Web/NotificationsController.php create mode 100644 app/Jobs/SendNotifications.php create mode 100644 app/Library/Notifications/Drivers/AbstractDriver.php create mode 100644 app/Library/Notifications/Drivers/PonyfmDriver.php create mode 100644 app/Library/Notifications/NotificationManager.php create mode 100644 app/Library/Notifications/RecipientFinder.php create mode 100644 app/Models/Activity.php create mode 100644 app/Models/Notification.php create mode 100644 app/Providers/NotificationServiceProvider.php create mode 100644 database/migrations/2016_04_06_152844_create_notifications_tables.php create mode 100644 documentation/notifications.md create mode 100644 public/templates/notifications/index.html create mode 100644 resources/assets/scripts/app/controllers/notifications.coffee create mode 100644 resources/assets/scripts/app/services/notifications.coffee diff --git a/README.md b/README.md index e4df4578..4e29f695 100644 --- a/README.md +++ b/README.md @@ -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 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 [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! diff --git a/app/Commands/CreateCommentCommand.php b/app/Commands/CreateCommentCommand.php index ade69196..2b401e85 100644 --- a/app/Commands/CreateCommentCommand.php +++ b/app/Commands/CreateCommentCommand.php @@ -20,6 +20,7 @@ namespace Poniverse\Ponyfm\Commands; +use Notification; use Poniverse\Ponyfm\Models\Album; use Poniverse\Ponyfm\Models\Comment; use Poniverse\Ponyfm\Models\Playlist; @@ -115,6 +116,8 @@ class CreateCommentCommand extends CommandBase $entity->comment_count = Comment::where($column, $this->_id)->count(); $entity->save(); + + Notification::newComment($comment); return CommandResponse::succeed(Comment::mapPublic($comment)); } diff --git a/app/Commands/CreatePlaylistCommand.php b/app/Commands/CreatePlaylistCommand.php index 51c9c8f7..6887ae9c 100644 --- a/app/Commands/CreatePlaylistCommand.php +++ b/app/Commands/CreatePlaylistCommand.php @@ -20,6 +20,7 @@ namespace Poniverse\Ponyfm\Commands; +use Notification; use Poniverse\Ponyfm\Models\Playlist; use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Validator; @@ -68,6 +69,8 @@ class CreatePlaylistCommand extends CommandBase $playlist->is_public = $this->_input['is_public'] == 'true'; $playlist->save(); + + Notification::publishedNewPlaylist($playlist); if ($this->_input['is_pinned'] == 'true') { $playlist->pin(Auth::user()->id); diff --git a/app/Commands/EditTrackCommand.php b/app/Commands/EditTrackCommand.php index a52c400e..972bf64b 100644 --- a/app/Commands/EditTrackCommand.php +++ b/app/Commands/EditTrackCommand.php @@ -21,6 +21,7 @@ namespace Poniverse\Ponyfm\Commands; use Gate; +use Notification; use Poniverse\Ponyfm\Models\Album; use Poniverse\Ponyfm\Models\Image; use Poniverse\Ponyfm\Models\Track; @@ -32,6 +33,10 @@ use DB; class EditTrackCommand extends CommandBase { private $_trackId; + + /** + * @var Track + */ private $_track; private $_input; @@ -132,6 +137,8 @@ class EditTrackCommand extends CommandBase DB::table('tracks')->whereUserId($track->user_id)->update(['is_latest' => false]); $track->is_latest = true; + + Notification::publishedNewTrack($track); } if (isset($this->_input['cover_id'])) { @@ -174,12 +181,13 @@ class EditTrackCommand extends CommandBase return CommandResponse::succeed(['real_cover_url' => $track->getCoverUrl(Image::NORMAL)]); } - private function removeTrackFromAlbum($track) + private function removeTrackFromAlbum(Track $track) { $album = $track->album; $index = 0; foreach ($album->tracks as $track) { + /** @var $track Track */ if ($track->id == $this->_trackId) { continue; } diff --git a/app/Commands/ToggleFavouriteCommand.php b/app/Commands/ToggleFavouriteCommand.php index c7bff8e3..267744aa 100644 --- a/app/Commands/ToggleFavouriteCommand.php +++ b/app/Commands/ToggleFavouriteCommand.php @@ -20,10 +20,14 @@ namespace Poniverse\Ponyfm\Commands; +use Notification; +use Poniverse\Ponyfm\Contracts\Favouritable; use Poniverse\Ponyfm\Models\Favourite; +use Poniverse\Ponyfm\Models\Playlist; use Poniverse\Ponyfm\Models\ResourceUser; use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\DB; +use Poniverse\Ponyfm\Models\Track; class ToggleFavouriteCommand extends CommandBase { @@ -45,6 +49,20 @@ class ToggleFavouriteCommand extends CommandBase 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 @@ -65,6 +83,8 @@ class ToggleFavouriteCommand extends CommandBase $fav->created_at = time(); $fav->save(); $isFavourited = true; + + Notification::newFavourite($this->getEntityBeingFavourited(), $fav->user); } $resourceUser = ResourceUser::get(Auth::user()->id, $this->_resourceType, $this->_resourceId); diff --git a/app/Commands/ToggleFollowingCommand.php b/app/Commands/ToggleFollowingCommand.php index fda5aa13..0a3a88a7 100644 --- a/app/Commands/ToggleFollowingCommand.php +++ b/app/Commands/ToggleFollowingCommand.php @@ -22,7 +22,8 @@ namespace Poniverse\Ponyfm\Commands; use Poniverse\Ponyfm\Models\Follower; use Poniverse\Ponyfm\Models\ResourceUser; -use Illuminate\Support\Facades\Auth; +use Auth; +use Notification; class ToggleFollowingCommand extends CommandBase { @@ -64,6 +65,8 @@ class ToggleFollowingCommand extends CommandBase $follow->created_at = time(); $follow->save(); $isFollowed = true; + + Notification::newFollower($follow->artist, Auth::user()); } $resourceUser = ResourceUser::get(Auth::user()->id, $this->_resourceType, $this->_resourceId); diff --git a/app/Contracts/Commentable.php b/app/Contracts/Commentable.php new file mode 100644 index 00000000..410e7ad2 --- /dev/null +++ b/app/Contracts/Commentable.php @@ -0,0 +1,38 @@ +. + */ + +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; +} diff --git a/app/Contracts/Favouritable.php b/app/Contracts/Favouritable.php new file mode 100644 index 00000000..29612156 --- /dev/null +++ b/app/Contracts/Favouritable.php @@ -0,0 +1,38 @@ +. + */ + +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; +} diff --git a/app/Contracts/GeneratesNotifications.php b/app/Contracts/GeneratesNotifications.php new file mode 100644 index 00000000..2a58c4d8 --- /dev/null +++ b/app/Contracts/GeneratesNotifications.php @@ -0,0 +1,42 @@ +. + */ + +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; +} diff --git a/app/Contracts/NotificationHandler.php b/app/Contracts/NotificationHandler.php new file mode 100644 index 00000000..25da4739 --- /dev/null +++ b/app/Contracts/NotificationHandler.php @@ -0,0 +1,68 @@ +. + */ + +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); +} diff --git a/app/Facades/Notification.php b/app/Facades/Notification.php new file mode 100644 index 00000000..479bd175 --- /dev/null +++ b/app/Facades/Notification.php @@ -0,0 +1,28 @@ +. + */ + +namespace Poniverse\Ponyfm\Facades; +use Illuminate\Support\Facades\Facade; + +class Notification extends Facade { + protected static function getFacadeAccessor() { + return 'notification'; + } +} diff --git a/app/Http/Controllers/Api/Web/AccountController.php b/app/Http/Controllers/Api/Web/AccountController.php index 051283d5..70f6fe40 100644 --- a/app/Http/Controllers/Api/Web/AccountController.php +++ b/app/Http/Controllers/Api/Web/AccountController.php @@ -20,10 +20,10 @@ namespace Poniverse\Ponyfm\Http\Controllers\Api\Web; +use Carbon\Carbon; use Poniverse\Ponyfm\Http\Controllers\ApiControllerBase; use Poniverse\Ponyfm\Commands\SaveAccountSettingsCommand; use Poniverse\Ponyfm\Models\User; -use Cover; use Gate; use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Input; diff --git a/app/Http/Controllers/Api/Web/NotificationsController.php b/app/Http/Controllers/Api/Web/NotificationsController.php new file mode 100644 index 00000000..fc9ae2ed --- /dev/null +++ b/app/Http/Controllers/Api/Web/NotificationsController.php @@ -0,0 +1,63 @@ +. + */ + +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]; + } +} diff --git a/app/Http/routes.php b/app/Http/routes.php index 7e9a47cd..ab05a173 100644 --- a/app/Http/routes.php +++ b/app/Http/routes.php @@ -134,6 +134,8 @@ Route::group(['prefix' => 'api/web'], function() { Route::group(['middleware' => 'auth'], function() { 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/edit/{id}', 'Api\Web\TracksController@getEdit'); diff --git a/app/Jobs/SendNotifications.php b/app/Jobs/SendNotifications.php new file mode 100644 index 00000000..d57a836a --- /dev/null +++ b/app/Jobs/SendNotifications.php @@ -0,0 +1,84 @@ +. + */ + +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()]; + } +} diff --git a/app/Library/Notifications/Drivers/AbstractDriver.php b/app/Library/Notifications/Drivers/AbstractDriver.php new file mode 100644 index 00000000..f6443850 --- /dev/null +++ b/app/Library/Notifications/Drivers/AbstractDriver.php @@ -0,0 +1,47 @@ +. + */ + +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); + } +} diff --git a/app/Library/Notifications/Drivers/PonyfmDriver.php b/app/Library/Notifications/Drivers/PonyfmDriver.php new file mode 100644 index 00000000..342db8f8 --- /dev/null +++ b/app/Library/Notifications/Drivers/PonyfmDriver.php @@ -0,0 +1,123 @@ +. + */ + +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())); + } +} diff --git a/app/Library/Notifications/NotificationManager.php b/app/Library/Notifications/NotificationManager.php new file mode 100644 index 00000000..13ac744d --- /dev/null +++ b/app/Library/Notifications/NotificationManager.php @@ -0,0 +1,83 @@ +. + */ + +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()); + } +} diff --git a/app/Library/Notifications/RecipientFinder.php b/app/Library/Notifications/RecipientFinder.php new file mode 100644 index 00000000..e92aef7c --- /dev/null +++ b/app/Library/Notifications/RecipientFinder.php @@ -0,0 +1,120 @@ +. + */ + +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(); + } + } +} diff --git a/app/Models/Activity.php b/app/Models/Activity.php new file mode 100644 index 00000000..858ee1e2 --- /dev/null +++ b/app/Models/Activity.php @@ -0,0 +1,205 @@ +. + */ + +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!'); + } + } +} diff --git a/app/Models/Album.php b/app/Models/Album.php index d4f2187e..2bad1246 100644 --- a/app/Models/Album.php +++ b/app/Models/Album.php @@ -23,10 +23,14 @@ namespace Poniverse\Ponyfm\Models; use Exception; use Helpers; use Illuminate\Database\Eloquent\Model; +use Illuminate\Database\Eloquent\Relations\HasMany; +use Illuminate\Database\Eloquent\Relations\MorphMany; use Illuminate\Database\Eloquent\SoftDeletes; use Auth; use Gate; use Cache; +use Poniverse\Ponyfm\Contracts\Commentable; +use Poniverse\Ponyfm\Contracts\Favouritable; use Poniverse\Ponyfm\Contracts\Searchable; use Poniverse\Ponyfm\Exceptions\TrackFileNotFoundException; use Poniverse\Ponyfm\Traits\IndexedInElasticsearchTrait; @@ -60,8 +64,9 @@ use Venturecraft\Revisionable\RevisionableTrait; * @property-read mixed $url * @property-read \Illuminate\Database\Eloquent\Collection|\Venturecraft\Revisionable\Revision[] $revisionHistory * @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; @@ -101,7 +106,7 @@ class Album extends Model implements Searchable return $this->hasMany('Poniverse\Ponyfm\Models\ResourceUser'); } - public function favourites() + public function favourites():HasMany { 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'); } - public function comments() + public function comments():HasMany { 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) { $tracks = []; @@ -429,4 +438,14 @@ class Album extends Model implements Searchable public function shouldBeIndexed():bool { 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'; + } } diff --git a/app/Models/Comment.php b/app/Models/Comment.php index 2b71b549..98847927 100644 --- a/app/Models/Comment.php +++ b/app/Models/Comment.php @@ -21,7 +21,10 @@ namespace Poniverse\Ponyfm\Models; use Illuminate\Database\Eloquent\Model; +use Illuminate\Database\Eloquent\Relations\MorphMany; use Illuminate\Database\Eloquent\SoftDeletes; +use Poniverse\Ponyfm\Contracts\Commentable; +use Poniverse\Ponyfm\Contracts\GeneratesNotifications; /** * 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\Playlist $playlist * @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 { @@ -78,6 +83,16 @@ class Comment extends Model 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) { return [ @@ -97,7 +112,7 @@ class Comment extends Model ]; } - public function getResourceAttribute() + public function getResourceAttribute():Commentable { if ($this->track_id !== null) { return $this->track; diff --git a/app/Models/Follower.php b/app/Models/Follower.php index 3357e76d..ac57142e 100644 --- a/app/Models/Follower.php +++ b/app/Models/Follower.php @@ -21,6 +21,7 @@ namespace Poniverse\Ponyfm\Models; use Illuminate\Database\Eloquent\Model; +use Illuminate\Database\Eloquent\Relations\BelongsTo; /** * Poniverse\Ponyfm\Models\Follower @@ -30,10 +31,21 @@ use Illuminate\Database\Eloquent\Model; * @property integer $artist_id * @property integer $playlist_id * @property string $created_at + * @property-read \Poniverse\Ponyfm\Models\User $follower + * @property-read \Poniverse\Ponyfm\Models\User $artist */ class Follower extends Model { protected $table = 'followers'; 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'); + } } diff --git a/app/Models/Notification.php b/app/Models/Notification.php new file mode 100644 index 00000000..f618ff69 --- /dev/null +++ b/app/Models/Notification.php @@ -0,0 +1,83 @@ +. + */ + +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 + ]; + } +} diff --git a/app/Models/Playlist.php b/app/Models/Playlist.php index c9f4d7ad..52a8be43 100644 --- a/app/Models/Playlist.php +++ b/app/Models/Playlist.php @@ -22,10 +22,14 @@ namespace Poniverse\Ponyfm\Models; use Helpers; use Illuminate\Database\Eloquent\Model; +use Illuminate\Database\Eloquent\Relations\HasMany; +use Illuminate\Database\Eloquent\Relations\MorphMany; use Illuminate\Database\Eloquent\SoftDeletes; use Illuminate\Foundation\Bus\DispatchesJobs; use Auth; use Cache; +use Poniverse\Ponyfm\Contracts\Commentable; +use Poniverse\Ponyfm\Contracts\Favouritable; use Poniverse\Ponyfm\Contracts\Searchable; use Poniverse\Ponyfm\Exceptions\TrackFileNotFoundException; use Poniverse\Ponyfm\Traits\IndexedInElasticsearchTrait; @@ -59,8 +63,10 @@ use Venturecraft\Revisionable\RevisionableTrait; * @property-read mixed $url * @property-read \Illuminate\Database\Eloquent\Collection|\Venturecraft\Revisionable\Revision[] $revisionHistory * @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; @@ -212,7 +218,7 @@ class Playlist extends Model implements Searchable 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'); } @@ -221,12 +227,20 @@ class Playlist extends Model implements Searchable { return $this->hasMany('Poniverse\Ponyfm\Models\PinnedPlaylist'); } + + public function favourites():HasMany { + return $this->hasMany(Favourite::class); + } public function user() { return $this->belongsTo('Poniverse\Ponyfm\Models\User'); } + public function activities():MorphMany { + return $this->morphMany(Activity::class, 'resource'); + } + public function hasPinFor($userId) { foreach ($this->pins as $pin) { @@ -324,4 +338,11 @@ class Playlist extends Model implements Searchable $this->track_count > 0 && !$this->trashed(); } + + /** + * @inheritdoc + */ + public function getResourceType():string { + return 'playlist'; + } } diff --git a/app/Models/Track.php b/app/Models/Track.php index 1944989a..862bdce4 100644 --- a/app/Models/Track.php +++ b/app/Models/Track.php @@ -25,6 +25,10 @@ use Cache; use Config; use DB; 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\Exceptions\TrackFileNotFoundException; 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 withComments() * @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; @@ -486,12 +492,12 @@ class Track extends Model implements Searchable 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'); } - public function favourites() + public function favourites():HasMany { return $this->hasMany('Poniverse\Ponyfm\Models\Favourite'); } @@ -526,6 +532,15 @@ class Track extends Model implements Searchable 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() { return date('Y', strtotime($this->getReleaseDate())); @@ -863,4 +878,8 @@ class Track extends Model implements Searchable 'show_songs' => $this->showSongs->pluck('title') ]; } + + public function getResourceType():string { + return 'track'; + } } diff --git a/app/Models/User.php b/app/Models/User.php index 89854764..964eefbb 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -26,9 +26,12 @@ use Illuminate\Auth\Passwords\CanResetPassword; use Illuminate\Contracts\Auth\Authenticatable as AuthenticatableContract; use Illuminate\Contracts\Auth\CanResetPassword as CanResetPasswordContract; use Illuminate\Database\Eloquent\Model; +use Illuminate\Database\Eloquent\Relations\HasMany; +use Illuminate\Database\Eloquent\Relations\MorphMany; use Illuminate\Foundation\Auth\Access\Authorizable; use Auth; use Illuminate\Support\Str; +use Poniverse\Ponyfm\Contracts\Commentable; use Poniverse\Ponyfm\Contracts\Searchable; use Poniverse\Ponyfm\Traits\IndexedInElasticsearchTrait; use Venturecraft\Revisionable\RevisionableTrait; @@ -63,8 +66,12 @@ use Venturecraft\Revisionable\RevisionableTrait; * @property-read mixed $message_url * @property-read \Illuminate\Database\Eloquent\Collection|\Venturecraft\Revisionable\Revision[] $revisionHistory * @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; @@ -133,13 +140,18 @@ class User extends Model implements AuthenticatableContract, CanResetPasswordCon { 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() { 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'); } @@ -148,6 +160,16 @@ class User extends Model implements AuthenticatableContract, CanResetPasswordCon { 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() { @@ -235,6 +257,21 @@ class User extends Model implements AuthenticatableContract, CanResetPasswordCon 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. * diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 58cc94cf..27d666c3 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -20,6 +20,7 @@ namespace Poniverse\Ponyfm\Providers; +use Illuminate\Database\Eloquent\Relations\Relation; use Illuminate\Foundation\Application; use Illuminate\Support\ServiceProvider; use PfmValidator; @@ -58,5 +59,16 @@ class AppServiceProvider extends ServiceProvider $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); } } diff --git a/app/Providers/NotificationServiceProvider.php b/app/Providers/NotificationServiceProvider.php new file mode 100644 index 00000000..76103e93 --- /dev/null +++ b/app/Providers/NotificationServiceProvider.php @@ -0,0 +1,50 @@ +. + */ + +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(); + }); + } +} diff --git a/config/app.php b/config/app.php index 16ee148e..3f5d52e9 100644 --- a/config/app.php +++ b/config/app.php @@ -145,6 +145,7 @@ return [ Poniverse\Ponyfm\Providers\EventServiceProvider::class, Poniverse\Ponyfm\Providers\RouteServiceProvider::class, Poniverse\Ponyfm\Providers\AuthServiceProvider::class, + Poniverse\Ponyfm\Providers\NotificationServiceProvider::class, Intouch\LaravelNewrelic\NewrelicServiceProvider::class, Barryvdh\LaravelIdeHelper\IdeHelperServiceProvider::class, @@ -201,6 +202,7 @@ return [ 'Elasticsearch' => Cviebrock\LaravelElasticsearch\Facade::class, 'Newrelic' => Intouch\LaravelNewrelic\Facades\Newrelic::class, + 'Notification' => Poniverse\Ponyfm\Facades\Notification::class, ], diff --git a/database/migrations/2016_04_06_152844_create_notifications_tables.php b/database/migrations/2016_04_06_152844_create_notifications_tables.php new file mode 100644 index 00000000..049daf13 --- /dev/null +++ b/database/migrations/2016_04_06_152844_create_notifications_tables.php @@ -0,0 +1,64 @@ +. + */ + +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'); + } +} diff --git a/documentation/notifications.md b/documentation/notifications.md new file mode 100644 index 00000000..55000cdb --- /dev/null +++ b/documentation/notifications.md @@ -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. diff --git a/public/templates/notifications/index.html b/public/templates/notifications/index.html new file mode 100644 index 00000000..e69de29b diff --git a/resources/assets/scripts/app/app.coffee b/resources/assets/scripts/app/app.coffee index 18a44b8d..a9550d11 100644 --- a/resources/assets/scripts/app/app.coffee +++ b/resources/assets/scripts/app/app.coffee @@ -285,6 +285,11 @@ ponyfm.config [ url: '/' templateUrl: '/templates/dashboard/index.html' controller: 'dashboard' + + state.state 'notifications', + url: '/notifications' + templateUrl: '/templates/notifications/index.html' + controller: 'notifications' else state.state 'home', url: '/' diff --git a/resources/assets/scripts/app/controllers/notifications.coffee b/resources/assets/scripts/app/controllers/notifications.coffee new file mode 100644 index 00000000..80bda43d --- /dev/null +++ b/resources/assets/scripts/app/controllers/notifications.coffee @@ -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 . + +module.exports = angular.module('ponyfm').controller "notifications", [ + '$scope', 'notifications' + ($scope, notifications) -> + notifications.getNotifications().done (result) -> + console.log result +] diff --git a/resources/assets/scripts/app/services/notifications.coffee b/resources/assets/scripts/app/services/notifications.coffee new file mode 100644 index 00000000..0205def9 --- /dev/null +++ b/resources/assets/scripts/app/services/notifications.coffee @@ -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 . + +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 +])