Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Enhancement - Campaign Cancellations #66

Merged
merged 16 commits into from
Oct 9, 2020
1 change: 1 addition & 0 deletions .php_cs.cache

Large diffs are not rendered by default.

22 changes: 16 additions & 6 deletions database/factories/CampaignFactory.php
Original file line number Diff line number Diff line change
Expand Up @@ -35,18 +35,28 @@
];
});

$factory->state(Campaign::class, 'sent', function () {
return [
'status_id' => CampaignStatus::STATUS_SENT,
];
});

$factory->state(Campaign::class, 'draft', function () {
return [
'status_id' => CampaignStatus::STATUS_DRAFT,
];
});

$factory->state(Campaign::class, 'queued', [
'status_id' => CampaignStatus::STATUS_QUEUED,
]);

$factory->state(Campaign::class, 'sending', [
'status_id' => CampaignStatus::STATUS_SENDING,
]);

$factory->state(Campaign::class, 'sent', [
'status_id' => CampaignStatus::STATUS_SENT,
]);

$factory->state(Campaign::class, 'cancelled', [
'status_id' => CampaignStatus::STATUS_CANCELLED,
]);

$factory->state(Campaign::class, 'withoutOpenTracking', static function () {
return ['is_open_tracking' => false];
});
Expand Down
8 changes: 8 additions & 0 deletions database/factories/MessageFactory.php
Original file line number Diff line number Diff line change
Expand Up @@ -34,3 +34,11 @@
'clicked_at' => null,
];
});

$factory->state(Message::class, 'dispatched', [
'sent_at' => now(),
]);

$factory->state(Message::class, 'pending', [
'sent_at' => null,
]);
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
<?php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;

class AddCancelledCampaignStatus extends Migration
{
public function up()
{
DB::table('campaign_statuses')
->insert([
'id' => 5,
'name' => 'Cancelled',
]);
}
}
5 changes: 4 additions & 1 deletion resources/lang/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -277,5 +277,8 @@
"You haven't added any email service": "You haven't added any email service",
"Your campaign is currently": "Your campaign is currently",
"Your profile was updated successfully": "Your profile was updated successfully",
"Zone": "Zone"
"Zone": "Zone",
"The campaign was cancelled whilst being processed (~:sent/:total dispatched).": "The campaign was cancelled whilst being processed (~:sent/:total dispatched).",
"Campaigns that save draft messages cannot be cancelled until all drafts have been created.": "Campaigns that save draft messages cannot be cancelled until all drafts have been created.",
"Messages already dispatched will not be deleted. Unsent messages will not be dispatched.": "Messages already dispatched will not be deleted. Unsent messages will not be dispatched."
}
46 changes: 46 additions & 0 deletions resources/views/campaigns/cancel.blade.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
@extends('sendportal::layouts.app')

@section('title', __('Cancel Campaign'))

@section('heading')
@lang('Cancel Campaign') - {{ $campaign->name }}
@endsection

@section('content')

@component('sendportal::layouts.partials.actions')
@slot('right')
<a class="btn btn-primary btn-md btn-flat" href="{{ route('sendportal.campaigns.create') }}">
<i class="fa fa-plus mr-1"></i> {{ __('Create Campaign') }}
</a>
@endslot
@endcomponent

<div class="card">
<div class="card-header card-header-accent">
<div class="card-header-inner">
{{ __('Confirm Cancellation') }}
</div>
</div>
<div class="card-body">
<p>
{!! __('Are you sure that you want to cancel the <b>:name</b> campaign?', ['name' => $campaign->name]) !!}
</p>

<p>
@if($campaign->save_as_draft)
{!! __('All draft messages will be permanently deleted.') !!}
@else
{!! __('Messages already dispatched will not be deleted. Unsent messages will not be dispatched.') !!}
@endif
</p>

<form action="{{ route('sendportal.campaigns.cancel', $campaign->id) }}" method="post">
@csrf
<a href="{{ route('sendportal.campaigns.index') }}" class="btn btn-md btn-light">{{ __('Go Back') }}</a>
<button type="submit" class="btn btn-md btn-danger">{{ __('CANCEL') }}</button>
</form>
</div>
</div>

@endsection
7 changes: 7 additions & 0 deletions resources/views/campaigns/index.blade.php
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,13 @@ class="dropdown-item">
{{ __('Duplicate') }}
</a>

@if($campaign->canBeCancelled())
<div class="dropdown-divider"></div>
<a href="{{ route('sendportal.campaigns.confirm-cancel', $campaign->id) }}"
class="dropdown-item">
{{ __('Cancel') }}
</a>
@endif

@if ($campaign->draft)
<div class="dropdown-divider"></div>
Expand Down
10 changes: 6 additions & 4 deletions resources/views/campaigns/partials/status.blade.php
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
@if($campaign->status_id === \Sendportal\Base\Models\CampaignStatus::STATUS_DRAFT)
@if($campaign->draft)
<span class="badge badge-light">{{ $campaign->status->name }}</span>
@elseif($campaign->status_id === \Sendportal\Base\Models\CampaignStatus::STATUS_QUEUED)
@elseif($campaign->queued)
<span class="badge badge-warning">{{ $campaign->status->name }}</span>
@elseif($campaign->status_id === \Sendportal\Base\Models\CampaignStatus::STATUS_SENDING)
@elseif($campaign->sending)
<span class="badge badge-warning">{{ $campaign->status->name }}</span>
@elseif($campaign->status_id === \Sendportal\Base\Models\CampaignStatus::STATUS_SENT)
@elseif($campaign->sent)
<span class="badge badge-success">{{ $campaign->status->name }}</span>
@elseif($campaign->cancelled)
<span class="badge badge-danger">{{ $campaign->status->name }}</span>
@endif
4 changes: 3 additions & 1 deletion resources/views/campaigns/status.blade.php
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,10 @@
</div>
</div>
<div class="card-body">
@if ($campaign->status_id === \Sendportal\Base\Models\CampaignStatus::STATUS_QUEUED)
@if ($campaign->queued)
Your campaign is queued and will be sent out soon.
@elseif ($campaign->cancelled)
Your campaign was cancelled.
@else
<i class="fas fa-cog fa-spin"></i>
{{ $campaignStats[$campaign->id]['counts']['sent'] }} out of {{ $campaignStats[$campaign->id]['counts']['total'] }} messages sent.
Expand Down
3 changes: 3 additions & 0 deletions routes/web.php
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,9 @@

$campaignRouter->get('{id}/duplicate', 'CampaignDuplicateController@duplicate')->name('duplicate');

$campaignRouter->get('{id}/confirm-cancel', 'CampaignCancellationController@confirm')->name('confirm-cancel');
$campaignRouter->post('{id}/cancel', 'CampaignCancellationController@cancel')->name('cancel');

$campaignRouter->get('{id}/report', 'CampaignReportsController@index')->name('reports.index');
$campaignRouter->get('{id}/report/recipients', 'CampaignReportsController@recipients')
->name('reports.recipients');
Expand Down
88 changes: 88 additions & 0 deletions src/Http/Controllers/Campaigns/CampaignCancellationController.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
<?php

declare(strict_types=1);

namespace Sendportal\Base\Http\Controllers\Campaigns;

use Exception;
use Illuminate\Validation\ValidationException;
use Illuminate\View\View;
use Sendportal\Base\Http\Controllers\Controller;
use Sendportal\Base\Models\Campaign;
use Sendportal\Base\Models\CampaignStatus;
use Sendportal\Base\Repositories\Campaigns\CampaignTenantRepositoryInterface;
use Sendportal\Base\Traits\ResolvesCurrentWorkspace;

class CampaignCancellationController extends Controller
{
use ResolvesCurrentWorkspace;

/** @var CampaignTenantRepositoryInterface $campaignRepository */
private $campaignRepository;

public function __construct(CampaignTenantRepositoryInterface $campaignRepository)
{
$this->campaignRepository = $campaignRepository;
}

/**
* @throws Exception
*/
public function confirm(int $campaignId)
{
$campaign = $this->campaignRepository->find($this->currentWorkspace()->id, $campaignId, ['status']);

return view('sendportal::campaigns.cancel', [
'campaign' => $campaign,
]);
}

/**
* @throws Exception
*/
public function cancel(int $campaignId)
{
/** @var Campaign $campaign */
$campaign = $this->campaignRepository->find($this->currentWorkspace()->id, $campaignId, ['status']);
$originalStatus = $campaign->status;

if (!$campaign->canBeCancelled()) {
throw ValidationException::withMessages([
'campaignStatus' => "{$campaign->status->name} campaigns cannot be cancelled.",
])->redirectTo(route('sendportal.campaigns.index'));
}

if ($campaign->save_as_draft && !$campaign->allDraftsCreated()) {
throw ValidationException::withMessages([
'messagesPendingDraft' => __('Campaigns that save draft messages cannot be cancelled until all drafts have been created.'),
])->redirectTo(route('sendportal.campaigns.index'));
}

$this->campaignRepository->cancelCampaign($campaign);

return redirect()->route('sendportal.campaigns.index')->with([
'success' => $this->getSuccessMessage($originalStatus, $campaign),
]);
}

private function getSuccessMessage(CampaignStatus $campaignStatus, Campaign $campaign): string
dljfield marked this conversation as resolved.
Show resolved Hide resolved
{
if ($campaignStatus->id === CampaignStatus::STATUS_QUEUED) {
return __('The queued campaign was cancelled successfully.');
}

if ($campaign->save_as_draft) {
return __('The campaign was cancelled and any remaining draft messages were deleted.');
}

$messageCounts = $this->campaignRepository->getCounts(collect($campaign->id), $campaign->workspace_id)[$campaign->id];

return __(
"The campaign was cancelled whilst being processed (~:sent/:total dispatched).",
[
'sent' => $messageCounts->sent,
'total' => $campaign->active_subscriber_count
]
);
}
}
35 changes: 35 additions & 0 deletions src/Models/Campaign.php
Original file line number Diff line number Diff line change
Expand Up @@ -250,6 +250,14 @@ public function getSentAttribute(): bool
return $this->status_id === CampaignStatus::STATUS_SENT;
}

/**
* Whether the campaign has been cancelled.
*/
public function getCancelledAttribute(): bool
{
return $this->status_id === CampaignStatus::STATUS_CANCELLED;
}

/**
* Get the number of unique opens for the campaign.
*/
Expand Down Expand Up @@ -281,4 +289,31 @@ public function getTotalClickCountAttribute(): int
{
return (int)$this->clicks()->sum('click_count');
}

/**
* Determine whether the campaign can be cancelled.
*/
public function canBeCancelled(): bool
{
// we can cancel campaigns that still have draft messages, because they haven't been entirely dispatched
// a campaign that doesn't have any more draft messages (i.e. they have all been sent) cannot be cancelled, because the campaign is completed

if ($this->status_id === CampaignStatus::STATUS_SENT && $this->save_as_draft && $this->sent_count !== $this->messages()->count()) {
return true;
}

return in_array($this->status_id, [CampaignStatus::STATUS_QUEUED, CampaignStatus::STATUS_SENDING], true);
}

/**
* Determine whether all drafts have been created for a campaign.
*/
public function allDraftsCreated(): bool
{
if (!$this->save_as_draft) {
return true;
}

return $this->active_subscriber_count === $this->messages()->count();
}
}
1 change: 1 addition & 0 deletions src/Models/CampaignStatus.php
Original file line number Diff line number Diff line change
Expand Up @@ -13,4 +13,5 @@ class CampaignStatus extends BaseModel
public const STATUS_QUEUED = 2;
public const STATUS_SENDING = 3;
public const STATUS_SENT = 4;
public const STATUS_CANCELLED = 5;
}
22 changes: 22 additions & 0 deletions src/Repositories/Campaigns/BaseCampaignTenantRepository.php
Original file line number Diff line number Diff line change
Expand Up @@ -48,10 +48,32 @@ public function getCounts(Collection $campaignIds, int $workspaceId): array
->selectRaw('count(case when messages.clicked_at IS NOT NULL then 1 end) as clicked')
->selectRaw('count(case when messages.sent_at IS NOT NULL then 1 end) as sent')
->selectRaw('count(case when messages.bounced_at IS NOT NULL then 1 end) as bounced')
->selectRaw('count(case when messages.sent_at IS NULL then 1 end) as pending')
->groupBy('campaigns.id')
->orderBy('campaigns.id')
->get();

return $counts->flatten()->keyBy('campaign_id')->toArray();
}

/**
* {@inheritDoc}
*/
public function cancelCampaign(Campaign $campaign): bool
{
$this->deleteDraftMessages($campaign);

return $campaign->update([
'status_id' => CampaignStatus::STATUS_CANCELLED,
]);
}

private function deleteDraftMessages(Campaign $campaign): void
{
if (! $campaign->save_as_draft) {
return;
}

$campaign->messages()->whereNull('sent_at')->delete();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -30,4 +30,9 @@ public function completedCampaigns(int $workspaceId, array $relations = []): Elo
* Get open counts and ratios for a campaign.
*/
public function getCounts(Collection $campaignIds, int $workspaceId): array;

/**
* Cancel a campaign.
*/
public function cancelCampaign(Campaign $campaign): bool;
}
Loading