Added per-component subscriptions. Closes #734

This commit is contained in:
James Brooks
2016-01-10 15:54:54 +00:00
committed by James Brooks
parent e5c137f82b
commit ac3888f7c8
29 changed files with 620 additions and 17 deletions

View File

@@ -11,6 +11,11 @@
namespace CachetHQ\Cachet\Bus\Commands\Subscriber;
/**
* This is the subscribe subscriber command.
*
* @author James Brooks <james@alt-three.com>
*/
final class SubscribeSubscriberCommand
{
/**
@@ -27,6 +32,13 @@ final class SubscribeSubscriberCommand
*/
public $verified;
/**
* The subscriptions that we want to add.
*
* @var array|null
*/
public $subscriptions;
/**
* The validation rules.
*
@@ -39,14 +51,16 @@ final class SubscribeSubscriberCommand
/**
* Create a new subscribe subscriber command instance.
*
* @param string $email
* @param bool $verified
* @param string $email
* @param bool $verified
* @param null|array $subscriptions
*
* @return void
*/
public function __construct($email, $verified = false)
public function __construct($email, $verified = false, $subscriptions = null)
{
$this->email = $email;
$this->verified = $verified;
$this->subscriptions = $subscriptions;
}
}

View File

@@ -0,0 +1,36 @@
<?php
/*
* This file is part of Cachet.
*
* (c) Alt Three Services Limited
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace CachetHQ\Cachet\Bus\Commands\Subscriber;
use CachetHQ\Cachet\Models\Subscription;
final class UnsubscribeSubscriptionCommand
{
/**
* The subscription to unsubscribe.
*
* @var \CachetHQ\Cachet\Models\Subscription
*/
public $subscription;
/**
* Create a unsubscribe subscription command instance.
*
* @param \CachetHQ\Cachet\Models\Subscription $subscription
*
* @return void
*/
public function __construct(Subscription $subscription)
{
$this->subscription = $subscription;
}
}

View File

@@ -0,0 +1,41 @@
<?php
/*
* This file is part of Cachet.
*
* (c) Alt Three Services Limited
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace CachetHQ\Cachet\Bus\Events\Subscriber;
use CachetHQ\Cachet\Models\Subscriber;
/**
* This is the subscriber has updated subscriptions event.
*
* @author James Brooks <james@alt-three.com>
*/
final class SubscriberHasUpdatedSubscriptionsEvent implements SubscriberEventInterface
{
/**
* The subscriber.
*
* @var \CachetHQ\Cachet\Models\Subscriber
*/
public $subscriber;
/**
* Create a new subscriber has updated subscriptions event instance.
*
* @param \CachetHQ\Cachet\Models\Subscriber $subscriber
*
* @return void
*/
public function __construct(Subscriber $subscriber)
{
$this->subscriber = $subscriber;
}
}

View File

@@ -14,9 +14,16 @@ namespace CachetHQ\Cachet\Bus\Handlers\Commands\Subscriber;
use CachetHQ\Cachet\Bus\Commands\Subscriber\SubscribeSubscriberCommand;
use CachetHQ\Cachet\Bus\Commands\Subscriber\VerifySubscriberCommand;
use CachetHQ\Cachet\Bus\Events\Subscriber\SubscriberHasSubscribedEvent;
use CachetHQ\Cachet\Bus\Events\Subscriber\SubscriberHasUpdatedSubscriptionsEvent;
use CachetHQ\Cachet\Bus\Exceptions\Subscriber\AlreadySubscribedException;
use CachetHQ\Cachet\Models\Subscriber;
use CachetHQ\Cachet\Models\Subscription;
/**
* This is the subscribe subscriber command handler.
*
* @author James Brooks <james@alt-three.com>
*/
class SubscribeSubscriberCommandHandler
{
/**
@@ -30,16 +37,29 @@ class SubscribeSubscriberCommandHandler
*/
public function handle(SubscribeSubscriberCommand $command)
{
if (Subscriber::where('email', $command->email)->first()) {
if (Subscriber::where('email', $command->email)->first() && $command->subscriptions === null) {
throw new AlreadySubscribedException("Cannot subscribe {$command->email} because they're already subscribed.");
}
$subscriber = Subscriber::create(['email' => $command->email]);
$subscriber = Subscriber::firstOrCreate(['email' => $command->email]);
if ($command->verified) {
dispatch(new VerifySubscriberCommand($subscriber));
if ($subscriptions = $command->subscriptions) {
foreach ($subscriptions as $subscription => $subscriptionValue) {
Subscription::firstOrCreate([
'subscriber_id' => $subscriber->id,
$subscription => $subscriptionValue,
]);
}
}
if ($subscriber->is_verified === false) {
if ($command->verified) {
dispatch(new VerifySubscriberCommand($subscriber));
} else {
event(new SubscriberHasSubscribedEvent($subscriber));
}
} else {
event(new SubscriberHasSubscribedEvent($subscriber));
event(new SubscriberHasUpdatedSubscriptionsEvent($subscriber));
}
return $subscriber;

View File

@@ -0,0 +1,35 @@
<?php
/*
* This file is part of Cachet.
*
* (c) Alt Three Services Limited
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace CachetHQ\Cachet\Bus\Handlers\Commands\Subscriber;
use CachetHQ\Cachet\Bus\Commands\Subscriber\UnsubscribeSubscriptionCommand;
use CachetHQ\Cachet\Bus\Events\Subscriber\SubscriberHasUnsubscribedEvent;
use CachetHQ\Cachet\Models\Subscription;
class UnsubscribeSubscriptionCommandHandler
{
/**
* Handle the unsubscribe subscription command.
*
* @param \CachetHQ\Cachet\Bus\Commands\Subscriber\UnsubscribeSubscriptionCommand $command
*
* @return void
*/
public function handle(UnsubscribeSubscriptionCommand $command)
{
$subscription = $command->subscription;
event(new SubscriberHasUnsubscribedEvent($subscription->subscriber));
$subscription->delete();
}
}

View File

@@ -29,6 +29,7 @@ class VerifySubscriberCommandHandler
{
$subscriber = $command->subscriber;
// Mark the subscriber as verified.
$subscriber->verified_at = Carbon::now();
$subscriber->save();

View File

@@ -0,0 +1,72 @@
<?php
/*
* This file is part of Cachet.
*
* (c) Alt Three Services Limited
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace CachetHQ\Cachet\Bus\Handlers\Events\Component;
use CachetHQ\Cachet\Bus\Events\Component\ComponentWasUpdatedEvent;
use CachetHQ\Cachet\Models\Component;
use CachetHQ\Cachet\Models\Subscription;
use Illuminate\Contracts\Mail\MailQueue;
use Illuminate\Mail\Message;
use McCool\LaravelAutoPresenter\Facades\AutoPresenter;
class SendComponentUpdateEmailNotificationHandler
{
/**
* The mailer instance.
*
* @var \Illuminate\Contracts\Mail\Mailer
*/
protected $mailer;
/**
* Create a new send incident email notification handler.
*
* @param \Illuminate\Contracts\Mail\Mailer $mailer
*
* @return void
*/
public function __construct(MailQueue $mailer)
{
$this->mailer = $mailer;
}
/**
* Handle the event.
*
* @param \CachetHQ\Cachet\Bus\Events\Component\ComponentWasUpdatedEvent $event
*
* @return void
*/
public function handle(ComponentWasUpdatedEvent $event)
{
$component = AutoPresenter::decorate($event->component);
$mail = [
'subject' => trans('cachet.subscriber.email.component.subject'),
'component_name' => $component->name,
'component_human_status' => $component->human_status,
];
foreach (Subscription::isVerifiedForComponent($component->id)->with('subscriber')->get() as $subscription) {
$subscriber = $subscription->subscriber;
$mail['email'] = $subscriber->email;
$mail['unsubscribe_link'] = route('subscribe.unsubscribe', ['code' => $subscriber->verify_code, 'subscription' => $subscription->id]);
$this->mailer->queue([
'html' => 'emails.components.update-html',
'text' => 'emails.components.update-text',
], $mail, function (Message $message) use ($mail) {
$message->to($mail['email'])->subject($mail['subject']);
});
}
}
}

View File

@@ -37,7 +37,7 @@ class EventServiceProvider extends ServiceProvider
//
],
'CachetHQ\Cachet\Bus\Events\Component\ComponentWasUpdatedEvent' => [
//
'CachetHQ\Cachet\Bus\Handlers\Events\Component\SendComponentUpdateEmailNotificationHandler',
],
'CachetHQ\Cachet\Bus\Events\Incident\IncidentWasReportedEvent' => [
'CachetHQ\Cachet\Bus\Handlers\Events\Incident\SendIncidentEmailNotificationHandler',
@@ -72,6 +72,9 @@ class EventServiceProvider extends ServiceProvider
'CachetHQ\Cachet\Bus\Events\Subscriber\SubscriberHasUnsubscribedEvent' => [
//
],
'CachetHQ\Cachet\Bus\Events\Subscriber\SubscriberHasUpdatedSubscriptionsEvent' => [
//
],
'CachetHQ\Cachet\Bus\Events\Subscriber\SubscriberHasVerifiedEvent' => [
//
],

View File

@@ -54,6 +54,7 @@ class RouteServiceProvider extends ServiceProvider
$this->app->router->model('metric_point', 'CachetHQ\Cachet\Models\MetricPoint');
$this->app->router->model('setting', 'CachetHQ\Cachet\Models\Setting');
$this->app->router->model('subscriber', 'CachetHQ\Cachet\Models\Subscriber');
$this->app->router->model('subscription', 'CachetHQ\Cachet\Models\Subscription');
$this->app->router->model('user', 'CachetHQ\Cachet\Models\User');
}

View File

@@ -13,7 +13,9 @@ namespace CachetHQ\Cachet\Http\Controllers\Api;
use CachetHQ\Cachet\Bus\Commands\Subscriber\SubscribeSubscriberCommand;
use CachetHQ\Cachet\Bus\Commands\Subscriber\UnsubscribeSubscriberCommand;
use CachetHQ\Cachet\Bus\Commands\Subscriber\UnsubscribeSubscriptionCommand;
use CachetHQ\Cachet\Models\Subscriber;
use CachetHQ\Cachet\Models\Subscription;
use GrahamCampbell\Binput\Facades\Binput;
use Illuminate\Database\QueryException;
use Illuminate\Support\Facades\Request;
@@ -41,7 +43,7 @@ class SubscriberController extends AbstractApiController
public function postSubscribers()
{
try {
$subscriber = dispatch(new SubscribeSubscriberCommand(Binput::get('email'), Binput::get('verify', false)));
$subscriber = dispatch(new SubscribeSubscriberCommand(Binput::get('email'), Binput::get('verify', false), null));
} catch (QueryException $e) {
throw new BadRequestHttpException();
}
@@ -62,4 +64,18 @@ class SubscriberController extends AbstractApiController
return $this->noContent();
}
/**
* Delete a subscriber.
*
* @param \CachetHQ\Cachet\Models\Subscriber $subscriber
*
* @return \Illuminate\Http\JsonResponse
*/
public function deleteSubscription(Subscription $subscriber)
{
dispatch(new UnsubscribeSubscriptionCommand($subscriber));
return $this->noContent();
}
}

View File

@@ -14,10 +14,12 @@ namespace CachetHQ\Cachet\Http\Controllers;
use AltThree\Validator\ValidationException;
use CachetHQ\Cachet\Bus\Commands\Subscriber\SubscribeSubscriberCommand;
use CachetHQ\Cachet\Bus\Commands\Subscriber\UnsubscribeSubscriberCommand;
use CachetHQ\Cachet\Bus\Commands\Subscriber\UnsubscribeSubscriptionCommand;
use CachetHQ\Cachet\Bus\Commands\Subscriber\VerifySubscriberCommand;
use CachetHQ\Cachet\Bus\Exceptions\Subscriber\AlreadySubscribedException;
use CachetHQ\Cachet\Facades\Setting;
use CachetHQ\Cachet\Models\Subscriber;
use CachetHQ\Cachet\Models\Subscription;
use GrahamCampbell\Binput\Facades\Binput;
use GrahamCampbell\Markdown\Facades\Markdown;
use Illuminate\Routing\Controller;
@@ -26,6 +28,11 @@ use Illuminate\Support\Facades\View;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
/**
* This is the subscribe controller.
*
* @author James Brooks <james@alt-three.com>
*/
class SubscribeController extends Controller
{
/**
@@ -47,9 +54,10 @@ class SubscribeController extends Controller
public function postSubscribe()
{
$email = Binput::get('email');
$subscriptions = Binput::get('subscriptions');
try {
dispatch(new SubscribeSubscriberCommand($email));
dispatch(new SubscribeSubscriberCommand($email, false, $subscriptions));
} catch (AlreadySubscribedException $e) {
return Redirect::route('subscribe.subscribe')
->withTitle(sprintf('<strong>%s</strong> %s', trans('dashboard.notifications.whoops'), trans('cachet.subscriber.email.failure')))
@@ -94,10 +102,11 @@ class SubscribeController extends Controller
* Handle the unsubscribe.
*
* @param string|null $code
* @param int|null $subscription
*
* @return \Illuminate\View\View
*/
public function getUnsubscribe($code = null)
public function getUnsubscribe($code = null, $subscription = null)
{
if ($code === null) {
throw new NotFoundHttpException();
@@ -109,7 +118,11 @@ class SubscribeController extends Controller
throw new BadRequestHttpException();
}
dispatch(new UnsubscribeSubscriberCommand($subscriber));
if ($subscription) {
dispatch(new UnsubscribeSubscriptionCommand(Subscription::forSubscriber($subscriber->id)->firstOrFail()));
} else {
dispatch(new UnsubscribeSubscriberCommand($subscriber, $subscription));
}
return Redirect::route('status-page')
->withSuccess(sprintf('<strong>%s</strong> %s', trans('dashboard.notifications.awesome'), trans('cachet.subscriber.email.unsubscribed')));

View File

@@ -68,6 +68,7 @@ class ApiRoutes
$router->delete('metrics/{metric}', 'MetricController@deleteMetric');
$router->delete('metrics/{metric}/points/{metric_point}', 'MetricPointController@deleteMetricPoint');
$router->delete('subscribers/{subscriber}', 'SubscriberController@deleteSubscriber');
$router->delete('subscriptions/{subscription}', 'SubscriberController@deleteSubscription');
});
});
}

View File

@@ -46,7 +46,7 @@ class SubscribeRoutes
'uses' => 'SubscribeController@getVerify',
]);
$router->get('unsubscribe/{code}', [
$router->get('unsubscribe/{code}/{subscription?}', [
'as' => 'unsubscribe',
'uses' => 'SubscribeController@getUnsubscribe',
]);

View File

@@ -62,6 +62,16 @@ class Subscriber extends Model implements HasPresenter
});
}
/**
* A subscriber has many subscriptions.
*
* @return \Illuminate\Database\Eloquent\Relations\HasMany
*/
public function subcriptions()
{
return $this->hasMany(Subscription::class);
}
/**
* Scope verified subscribers.
*

113
app/Models/Subscription.php Normal file
View File

@@ -0,0 +1,113 @@
<?php
/*
* This file is part of Cachet.
*
* (c) Alt Three Services Limited
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace CachetHQ\Cachet\Models;
use AltThree\Validator\ValidatingTrait;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
class Subscription extends Model
{
use ValidatingTrait;
/**
* The attributes that should be casted to native types.
*
* @var string[]
*/
protected $casts = [
'subscriber_id' => 'int',
'component_id' => 'int',
];
/**
* The fillable properties.
*
* @var string[]
*/
protected $fillable = [
'subscriber_id',
'component_id',
];
/**
* The validation rules.
*
* @var string[]
*/
public $rules = [
'subscriber_id' => 'int|required',
'component_id' => 'int',
];
/**
* A subscription belongs to a subscriber.
*
* @return \Illuminate\Database\Eloquent\Relations\BelongsTo
*/
public function subscriber()
{
return $this->belongsTo(Subscriber::class);
}
/**
* A subscription has one component.
*
* @return \Illuminate\Database\Eloquent\Relations\HasOne
*/
public function component()
{
return $this->belongsTo(Component::class);
}
/**
* Finds all subscriptions for a given subscriber.
*
* @param \Illuminate\Database\Eloquent\Builder $query
* @param int $subscriber_id
*
* @return \Illuminate\Database\Eloquent\Builder
*/
public function scopeForSubscriber(Builder $query, $subscriber_id)
{
return $query->where('subscriber_id', $subscriber_id);
}
/**
* Finds all subscriptions for a component.
*
* @param \Illuminate\Database\Eloquent\Builder $query
* @param int $component_id
*
* @return \Illuminate\Database\Eloquent\Builder
*/
public function scopeForComponent(Builder $query, $component_id)
{
return $query->where('component_id', $component_id);
}
/**
* Finds all verified subscriptions for a component.
*
* @param \Illuminate\Database\Eloquent\Builder $query
* @param int $component_id
*
* @return \Illuminate\Database\Eloquent\Builder
*/
public function scopeIsVerifiedForComponent(Builder $query, $component_id)
{
return $query->select('subscriptions.*')
->join('subscribers', 'subscriptions.subscriber_id', '=', 'subscribers.id')
->where('component_id', $component_id)
->whereNotNull('subscribers.verified_at');
}
}