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
+])