Add SMS bulk delay configuration and validate coupon phone function
This commit is contained in:
11
app/Enums/SmsSendStatus.php
Normal file
11
app/Enums/SmsSendStatus.php
Normal file
@@ -0,0 +1,11 @@
|
||||
<?php
|
||||
|
||||
namespace App\Enums;
|
||||
|
||||
enum SmsSendStatus: string
|
||||
{
|
||||
case Pending = 'pending';
|
||||
case Sent = 'sent';
|
||||
case Failed = 'failed';
|
||||
case SkippedInvalid = 'skipped_invalid';
|
||||
}
|
||||
373
app/Filament/Pages/SendSms.php
Normal file
373
app/Filament/Pages/SendSms.php
Normal file
@@ -0,0 +1,373 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Pages;
|
||||
|
||||
use App\Models\Coupon;
|
||||
use App\Models\SmsCampaign;
|
||||
use App\Services\SmsBroadcastService;
|
||||
use App\Services\SmsMessageAnalyzer;
|
||||
use BackedEnum;
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Forms\Components\Checkbox;
|
||||
use Filament\Forms\Components\Select;
|
||||
use Filament\Forms\Components\Textarea;
|
||||
use Filament\Notifications\Notification;
|
||||
use Filament\Pages\Page;
|
||||
use Filament\Schemas\Components\Actions;
|
||||
use Filament\Schemas\Components\EmbeddedSchema;
|
||||
use Filament\Schemas\Components\Form;
|
||||
use Filament\Schemas\Components\Section;
|
||||
use Filament\Schemas\Components\Utilities\Get;
|
||||
use Filament\Schemas\Schema;
|
||||
use Filament\Support\Icons\Heroicon;
|
||||
use Illuminate\Support\Facades\Validator;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
/**
|
||||
* @property-read Schema $form
|
||||
*/
|
||||
class SendSms extends Page
|
||||
{
|
||||
protected static string|BackedEnum|null $navigationIcon = Heroicon::OutlinedChatBubbleLeftRight;
|
||||
|
||||
protected static ?string $navigationLabel = 'Send SMS';
|
||||
|
||||
protected static ?string $title = 'Send SMS';
|
||||
|
||||
protected static ?int $navigationSort = 2;
|
||||
|
||||
protected string $view = 'filament.pages.send-sms';
|
||||
|
||||
/**
|
||||
* @var array<string, mixed>|null
|
||||
*/
|
||||
public ?array $data = [];
|
||||
|
||||
public ?int $activeCampaignId = null;
|
||||
|
||||
public bool $campaignNotificationSent = false;
|
||||
|
||||
public function mount(): void
|
||||
{
|
||||
$this->form->fill([
|
||||
'message' => '',
|
||||
'send_to_all' => true,
|
||||
'coupon_ids' => [],
|
||||
'excluded_coupon_ids' => [],
|
||||
]);
|
||||
}
|
||||
|
||||
public function defaultForm(Schema $schema): Schema
|
||||
{
|
||||
return $schema
|
||||
->statePath('data')
|
||||
->columns(1);
|
||||
}
|
||||
|
||||
public function form(Schema $schema): Schema
|
||||
{
|
||||
return $schema
|
||||
->components([
|
||||
Section::make('Message')
|
||||
->description('Write the SMS exactly as recipients should see it.')
|
||||
->schema([
|
||||
Textarea::make('message')
|
||||
->label('SMS message')
|
||||
->required()
|
||||
->rows(8)
|
||||
->maxLength(1000)
|
||||
->live(debounce: 300)
|
||||
->helperText(fn (Get $get): string => app(SmsMessageAnalyzer::class)->summary((string) ($get('message') ?? ''))),
|
||||
]),
|
||||
Section::make('Recipients')
|
||||
->description('Send to everyone, pick specific coupon holders, or exclude numbers from a broadcast.')
|
||||
->schema([
|
||||
Checkbox::make('send_to_all')
|
||||
->label('Send to all coupon holders')
|
||||
->helperText('When enabled, every coupon holder is included. Manual selection is disabled.')
|
||||
->live(),
|
||||
Select::make('coupon_ids')
|
||||
->label('Select recipients')
|
||||
->multiple()
|
||||
->searchable()
|
||||
->disabled(fn (Get $get): bool => (bool) $get('send_to_all'))
|
||||
->dehydrated(fn (Get $get): bool => ! (bool) $get('send_to_all'))
|
||||
->helperText('Search by phone, coupon code, or ID.')
|
||||
->getSearchResultsUsing(fn (string $search): array => $this->searchCoupons($search))
|
||||
->getOptionLabelsUsing(fn (array $values): array => $this->couponLabels($values)),
|
||||
Select::make('excluded_coupon_ids')
|
||||
->label('Exclude recipients')
|
||||
->multiple()
|
||||
->searchable()
|
||||
->helperText('Excluded numbers will not receive this message, even when sending to all.')
|
||||
->getSearchResultsUsing(fn (string $search): array => $this->searchCoupons($search))
|
||||
->getOptionLabelsUsing(fn (array $values): array => $this->couponLabels($values)),
|
||||
]),
|
||||
]);
|
||||
}
|
||||
|
||||
public function content(Schema $schema): Schema
|
||||
{
|
||||
return $schema
|
||||
->components([
|
||||
Form::make([EmbeddedSchema::make('form')])
|
||||
->id('send-sms-form')
|
||||
->livewireSubmitHandler('sendCampaign')
|
||||
->footer([
|
||||
Actions::make([
|
||||
Action::make('send')
|
||||
->label('Send SMS')
|
||||
->icon(Heroicon::OutlinedPaperAirplane)
|
||||
->requiresConfirmation()
|
||||
->modalHeading('Send SMS now?')
|
||||
->modalDescription(fn (): string => $this->confirmationDescription())
|
||||
->modalSubmitActionLabel('Send')
|
||||
->action('sendCampaign')
|
||||
->disabled(fn (): bool => $this->activeCampaignId !== null && ! $this->isCampaignComplete()),
|
||||
]),
|
||||
]),
|
||||
]);
|
||||
}
|
||||
|
||||
public function sendCampaign(): void
|
||||
{
|
||||
if ($this->activeCampaignId !== null && ! $this->isCampaignComplete()) {
|
||||
Notification::make()
|
||||
->title('A campaign is still sending')
|
||||
->warning()
|
||||
->send();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$data = $this->form->getState();
|
||||
|
||||
$validator = Validator::make($data, [
|
||||
'message' => ['required', 'string', 'max:1000'],
|
||||
'send_to_all' => ['required', 'boolean'],
|
||||
'coupon_ids' => [
|
||||
Rule::requiredIf(fn (): bool => ! ($data['send_to_all'] ?? false)),
|
||||
'array',
|
||||
],
|
||||
'coupon_ids.*' => ['integer', 'exists:coupons,id'],
|
||||
'excluded_coupon_ids' => ['nullable', 'array'],
|
||||
'excluded_coupon_ids.*' => ['integer', 'exists:coupons,id'],
|
||||
]);
|
||||
|
||||
$validator->validate();
|
||||
|
||||
$sendToAll = (bool) $data['send_to_all'];
|
||||
$selectedIds = array_map('intval', $data['coupon_ids'] ?? []);
|
||||
$excludedIds = array_map('intval', $data['excluded_coupon_ids'] ?? []);
|
||||
|
||||
$service = app(SmsBroadcastService::class);
|
||||
$willReceive = $service->resolveRecipients($sendToAll, $selectedIds, $excludedIds)
|
||||
->filter(fn (Coupon $coupon): bool => is_valid_coupon_phone($coupon->phone))
|
||||
->count();
|
||||
|
||||
if ($willReceive === 0) {
|
||||
Notification::make()
|
||||
->title('No valid recipients')
|
||||
->body('Adjust your selection or exclusions — no coupon holders with valid phone numbers will receive this message.')
|
||||
->danger()
|
||||
->send();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$campaign = $service->startCampaign(
|
||||
admin: auth()->user(),
|
||||
message: $data['message'],
|
||||
sendToAll: $sendToAll,
|
||||
selectedIds: $selectedIds,
|
||||
excludedIds: $excludedIds,
|
||||
);
|
||||
|
||||
$this->activeCampaignId = $campaign->id;
|
||||
$this->campaignNotificationSent = false;
|
||||
|
||||
if ($campaign->isComplete()) {
|
||||
$this->notifyCampaignFinished($campaign);
|
||||
} else {
|
||||
Notification::make()
|
||||
->title('SMS campaign queued')
|
||||
->body("Sending to {$campaign->recipient_count} recipient(s) in the background.")
|
||||
->success()
|
||||
->send();
|
||||
}
|
||||
}
|
||||
|
||||
public function refreshCampaignProgress(): void
|
||||
{
|
||||
if ($this->activeCampaignId === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
$campaign = SmsCampaign::query()->find($this->activeCampaignId);
|
||||
|
||||
if ($campaign === null) {
|
||||
$this->activeCampaignId = null;
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if ($campaign->isComplete()) {
|
||||
$this->notifyCampaignFinished($campaign);
|
||||
}
|
||||
}
|
||||
|
||||
protected function notifyCampaignFinished(SmsCampaign $campaign): void
|
||||
{
|
||||
if ($this->campaignNotificationSent) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->campaignNotificationSent = true;
|
||||
$campaign->refresh();
|
||||
|
||||
if ($campaign->failed_count > 0 && $campaign->sent_count === 0) {
|
||||
Notification::make()
|
||||
->title('SMS campaign failed')
|
||||
->body("{$campaign->failed_count} message(s) failed to send.")
|
||||
->danger()
|
||||
->send();
|
||||
} elseif ($campaign->failed_count > 0) {
|
||||
Notification::make()
|
||||
->title('SMS campaign finished with errors')
|
||||
->body("Sent {$campaign->sent_count}, failed {$campaign->failed_count}, skipped {$campaign->skipped_count}.")
|
||||
->warning()
|
||||
->send();
|
||||
} else {
|
||||
Notification::make()
|
||||
->title('SMS campaign completed')
|
||||
->body("Successfully sent {$campaign->sent_count} message(s).")
|
||||
->success()
|
||||
->send();
|
||||
}
|
||||
}
|
||||
|
||||
protected function isCampaignComplete(): bool
|
||||
{
|
||||
if ($this->activeCampaignId === null) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return SmsCampaign::query()
|
||||
->whereKey($this->activeCampaignId)
|
||||
->whereNotNull('completed_at')
|
||||
->exists();
|
||||
}
|
||||
|
||||
protected function confirmationDescription(): string
|
||||
{
|
||||
$message = (string) ($this->data['message'] ?? '');
|
||||
$analyzer = app(SmsMessageAnalyzer::class);
|
||||
$willReceive = $this->getWillReceiveCount();
|
||||
|
||||
return "Send to {$willReceive} recipient(s)? {$analyzer->summary($message)}";
|
||||
}
|
||||
|
||||
public function getWillReceiveCount(): int
|
||||
{
|
||||
$data = $this->data ?? [];
|
||||
|
||||
return app(SmsBroadcastService::class)
|
||||
->resolveRecipients(
|
||||
(bool) ($data['send_to_all'] ?? false),
|
||||
array_map('intval', $data['coupon_ids'] ?? []),
|
||||
array_map('intval', $data['excluded_coupon_ids'] ?? []),
|
||||
)
|
||||
->filter(fn (Coupon $coupon): bool => is_valid_coupon_phone($coupon->phone))
|
||||
->count();
|
||||
}
|
||||
|
||||
public function getCampaignProgressPercent(): int
|
||||
{
|
||||
if ($this->activeCampaignId === null) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$campaign = SmsCampaign::query()->find($this->activeCampaignId);
|
||||
|
||||
return $campaign?->progressPercent() ?? 0;
|
||||
}
|
||||
|
||||
public function getCampaignProgressLabel(): string
|
||||
{
|
||||
if ($this->activeCampaignId === null) {
|
||||
return '';
|
||||
}
|
||||
|
||||
$campaign = SmsCampaign::query()->find($this->activeCampaignId);
|
||||
|
||||
if ($campaign === null) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return "{$campaign->processedCount()} / {$campaign->recipient_count} processed · {$campaign->sent_count} sent";
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
protected function getViewData(): array
|
||||
{
|
||||
return [
|
||||
'campaignProgressPercent' => $this->getCampaignProgressPercent(),
|
||||
'campaignProgressLabel' => $this->getCampaignProgressLabel(),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, string>
|
||||
*/
|
||||
protected function searchCoupons(string $search): array
|
||||
{
|
||||
$search = trim($search);
|
||||
|
||||
if ($search === '') {
|
||||
return [];
|
||||
}
|
||||
|
||||
return Coupon::query()
|
||||
->where(function ($query) use ($search): void {
|
||||
$query->where('phone', 'like', "%{$search}%")
|
||||
->orWhere('code', 'like', "%{$search}%");
|
||||
|
||||
if (ctype_digit($search)) {
|
||||
$query->orWhere('id', (int) $search);
|
||||
}
|
||||
})
|
||||
->orderBy('id')
|
||||
->limit(50)
|
||||
->get()
|
||||
->mapWithKeys(fn (Coupon $coupon): array => [
|
||||
$coupon->id => $this->couponLabel($coupon),
|
||||
])
|
||||
->all();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, int|string> $values
|
||||
* @return array<int, string>
|
||||
*/
|
||||
protected function couponLabels(array $values): array
|
||||
{
|
||||
if ($values === []) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return Coupon::query()
|
||||
->whereIn('id', $values)
|
||||
->get()
|
||||
->mapWithKeys(fn (Coupon $coupon): array => [
|
||||
$coupon->id => $this->couponLabel($coupon),
|
||||
])
|
||||
->all();
|
||||
}
|
||||
|
||||
protected function couponLabel(Coupon $coupon): string
|
||||
{
|
||||
return format_phone($coupon->phone).' · #'.$coupon->id.' · '.$coupon->code;
|
||||
}
|
||||
}
|
||||
@@ -76,6 +76,16 @@ if (! function_exists('unmask_phone')) {
|
||||
}
|
||||
}
|
||||
|
||||
if (! function_exists('is_valid_coupon_phone')) {
|
||||
/**
|
||||
* Whether an 8-digit stored coupon phone can receive SMS.
|
||||
*/
|
||||
function is_valid_coupon_phone(string $phone): bool
|
||||
{
|
||||
return (bool) preg_match('/^6\d{7}$/', $phone);
|
||||
}
|
||||
}
|
||||
|
||||
if (! function_exists('format_phone')) {
|
||||
/**
|
||||
* Format an 8-digit TM mobile number for display (+993 6X XX XX XX).
|
||||
|
||||
81
app/Jobs/SendSmsToCouponJob.php
Normal file
81
app/Jobs/SendSmsToCouponJob.php
Normal file
@@ -0,0 +1,81 @@
|
||||
<?php
|
||||
|
||||
namespace App\Jobs;
|
||||
|
||||
use App\Enums\SmsSendStatus;
|
||||
use App\Models\Coupon;
|
||||
use App\Models\SmsCampaign;
|
||||
use App\Models\SmsSendLog;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Queue\Queueable;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
class SendSmsToCouponJob implements ShouldQueue
|
||||
{
|
||||
use Queueable;
|
||||
use SerializesModels;
|
||||
|
||||
public int $tries = 3;
|
||||
|
||||
public function __construct(
|
||||
public SmsCampaign $campaign,
|
||||
public Coupon $coupon,
|
||||
) {
|
||||
$this->onQueue('sms');
|
||||
}
|
||||
|
||||
public function handle(): void
|
||||
{
|
||||
$log = SmsSendLog::query()
|
||||
->where('sms_campaign_id', $this->campaign->id)
|
||||
->where('coupon_id', $this->coupon->id)
|
||||
->first();
|
||||
|
||||
if ($log === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (! is_valid_coupon_phone($this->coupon->phone)) {
|
||||
$this->markSkipped($log);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$success = sendSMS($this->coupon->phone, $this->campaign->message);
|
||||
|
||||
$log->update([
|
||||
'status' => $success ? SmsSendStatus::Sent : SmsSendStatus::Failed,
|
||||
'error_message' => $success ? null : 'SMS API request failed',
|
||||
'attempted_at' => now(),
|
||||
]);
|
||||
|
||||
$this->campaign->increment($success ? 'sent_count' : 'failed_count');
|
||||
$this->markCampaignCompleteIfFinished();
|
||||
|
||||
usleep((int) config('services.sms.bulk_delay_ms', 300) * 1000);
|
||||
}
|
||||
|
||||
protected function markSkipped(SmsSendLog $log): void
|
||||
{
|
||||
if ($log->status === SmsSendStatus::SkippedInvalid) {
|
||||
return;
|
||||
}
|
||||
|
||||
$log->update([
|
||||
'status' => SmsSendStatus::SkippedInvalid,
|
||||
'attempted_at' => now(),
|
||||
]);
|
||||
|
||||
$this->campaign->increment('skipped_count');
|
||||
$this->markCampaignCompleteIfFinished();
|
||||
}
|
||||
|
||||
protected function markCampaignCompleteIfFinished(): void
|
||||
{
|
||||
$this->campaign->refresh();
|
||||
|
||||
if ($this->campaign->processedCount() >= $this->campaign->recipient_count) {
|
||||
$this->campaign->update(['completed_at' => now()]);
|
||||
}
|
||||
}
|
||||
}
|
||||
66
app/Models/SmsCampaign.php
Normal file
66
app/Models/SmsCampaign.php
Normal file
@@ -0,0 +1,66 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Database\Factories\SmsCampaignFactory;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
class SmsCampaign extends Model
|
||||
{
|
||||
/** @use HasFactory<SmsCampaignFactory> */
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = [
|
||||
'message',
|
||||
'mode',
|
||||
'recipient_count',
|
||||
'sent_count',
|
||||
'failed_count',
|
||||
'skipped_count',
|
||||
'laravel_batch_id',
|
||||
'created_by',
|
||||
'completed_at',
|
||||
];
|
||||
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'completed_at' => 'datetime',
|
||||
];
|
||||
}
|
||||
|
||||
public function creator(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'created_by');
|
||||
}
|
||||
|
||||
public function sendLogs(): HasMany
|
||||
{
|
||||
return $this->hasMany(SmsSendLog::class);
|
||||
}
|
||||
|
||||
public function isComplete(): bool
|
||||
{
|
||||
return $this->completed_at !== null;
|
||||
}
|
||||
|
||||
public function processedCount(): int
|
||||
{
|
||||
return $this->sent_count + $this->failed_count + $this->skipped_count;
|
||||
}
|
||||
|
||||
public function progressPercent(): int
|
||||
{
|
||||
if ($this->recipient_count === 0) {
|
||||
return 100;
|
||||
}
|
||||
|
||||
return (int) min(100, round(($this->processedCount() / $this->recipient_count) * 100));
|
||||
}
|
||||
}
|
||||
46
app/Models/SmsSendLog.php
Normal file
46
app/Models/SmsSendLog.php
Normal file
@@ -0,0 +1,46 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Enums\SmsSendStatus;
|
||||
use Database\Factories\SmsSendLogFactory;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class SmsSendLog extends Model
|
||||
{
|
||||
/** @use HasFactory<SmsSendLogFactory> */
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = [
|
||||
'sms_campaign_id',
|
||||
'coupon_id',
|
||||
'phone',
|
||||
'message',
|
||||
'status',
|
||||
'error_message',
|
||||
'attempted_at',
|
||||
];
|
||||
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'status' => SmsSendStatus::class,
|
||||
'attempted_at' => 'datetime',
|
||||
];
|
||||
}
|
||||
|
||||
public function campaign(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(SmsCampaign::class, 'sms_campaign_id');
|
||||
}
|
||||
|
||||
public function coupon(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Coupon::class);
|
||||
}
|
||||
}
|
||||
97
app/Services/SmsBroadcastService.php
Normal file
97
app/Services/SmsBroadcastService.php
Normal file
@@ -0,0 +1,97 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Enums\SmsSendStatus;
|
||||
use App\Jobs\SendSmsToCouponJob;
|
||||
use App\Models\Coupon;
|
||||
use App\Models\SmsCampaign;
|
||||
use App\Models\SmsSendLog;
|
||||
use App\Models\User;
|
||||
use Illuminate\Database\Eloquent\Collection;
|
||||
use Illuminate\Support\Facades\Bus;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class SmsBroadcastService
|
||||
{
|
||||
/**
|
||||
* @param array<int, int|string> $selectedIds
|
||||
* @param array<int, int|string> $excludedIds
|
||||
* @return Collection<int, Coupon>
|
||||
*/
|
||||
public function resolveRecipients(bool $sendToAll, array $selectedIds = [], array $excludedIds = []): Collection
|
||||
{
|
||||
$excludedIds = array_values(array_unique(array_map('intval', $excludedIds)));
|
||||
|
||||
if ($sendToAll) {
|
||||
$query = Coupon::query();
|
||||
} else {
|
||||
$selectedIds = array_values(array_unique(array_map('intval', $selectedIds)));
|
||||
$query = Coupon::query()->whereIn('id', $selectedIds);
|
||||
}
|
||||
|
||||
if ($excludedIds !== []) {
|
||||
$query->whereNotIn('id', $excludedIds);
|
||||
}
|
||||
|
||||
return $query->orderBy('id')->get();
|
||||
}
|
||||
|
||||
public function startCampaign(
|
||||
User $admin,
|
||||
string $message,
|
||||
bool $sendToAll,
|
||||
array $selectedIds = [],
|
||||
array $excludedIds = [],
|
||||
): SmsCampaign {
|
||||
$recipients = $this->resolveRecipients($sendToAll, $selectedIds, $excludedIds);
|
||||
$mode = $sendToAll ? 'all' : 'selected';
|
||||
|
||||
return DB::transaction(function () use ($admin, $message, $mode, $recipients): SmsCampaign {
|
||||
$campaign = SmsCampaign::query()->create([
|
||||
'message' => $message,
|
||||
'mode' => $mode,
|
||||
'recipient_count' => $recipients->count(),
|
||||
'created_by' => $admin->id,
|
||||
]);
|
||||
|
||||
$jobs = [];
|
||||
|
||||
foreach ($recipients as $coupon) {
|
||||
if (! is_valid_coupon_phone($coupon->phone)) {
|
||||
$this->createLog($campaign, $coupon, SmsSendStatus::SkippedInvalid);
|
||||
$campaign->increment('skipped_count');
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$this->createLog($campaign, $coupon, SmsSendStatus::Pending);
|
||||
$jobs[] = new SendSmsToCouponJob($campaign, $coupon);
|
||||
}
|
||||
|
||||
if ($jobs === []) {
|
||||
$campaign->update(['completed_at' => now()]);
|
||||
|
||||
return $campaign->fresh();
|
||||
}
|
||||
|
||||
Bus::chain($jobs)
|
||||
->onQueue('sms')
|
||||
->dispatch();
|
||||
|
||||
return $campaign->fresh();
|
||||
});
|
||||
}
|
||||
|
||||
protected function createLog(SmsCampaign $campaign, Coupon $coupon, SmsSendStatus $status): SmsSendLog
|
||||
{
|
||||
return SmsSendLog::query()->create([
|
||||
'sms_campaign_id' => $campaign->id,
|
||||
'coupon_id' => $coupon->id,
|
||||
'phone' => $coupon->phone,
|
||||
'message' => $campaign->message,
|
||||
'status' => $status,
|
||||
'attempted_at' => $status === SmsSendStatus::Pending ? null : now(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
65
app/Services/SmsMessageAnalyzer.php
Normal file
65
app/Services/SmsMessageAnalyzer.php
Normal file
@@ -0,0 +1,65 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
class SmsMessageAnalyzer
|
||||
{
|
||||
private const GSM_SINGLE_LIMIT = 160;
|
||||
|
||||
private const GSM_MULTI_LIMIT = 153;
|
||||
|
||||
private const UCS2_SINGLE_LIMIT = 70;
|
||||
|
||||
private const UCS2_MULTI_LIMIT = 67;
|
||||
|
||||
public function characterCount(string $message): int
|
||||
{
|
||||
return mb_strlen($message);
|
||||
}
|
||||
|
||||
public function isGsm7(string $message): bool
|
||||
{
|
||||
if ($message === '') {
|
||||
return true;
|
||||
}
|
||||
|
||||
return ! preg_match('/[^\x00-\x7F]/', $message);
|
||||
}
|
||||
|
||||
public function estimatedParts(string $message): int
|
||||
{
|
||||
$length = $this->characterCount($message);
|
||||
|
||||
if ($length === 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
if ($this->isGsm7($message)) {
|
||||
if ($length <= self::GSM_SINGLE_LIMIT) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
return (int) ceil($length / self::GSM_MULTI_LIMIT);
|
||||
}
|
||||
|
||||
if ($length <= self::UCS2_SINGLE_LIMIT) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
return (int) ceil($length / self::UCS2_MULTI_LIMIT);
|
||||
}
|
||||
|
||||
public function summary(string $message): string
|
||||
{
|
||||
$characters = $this->characterCount($message);
|
||||
$parts = $this->estimatedParts($message);
|
||||
|
||||
if ($parts === 0) {
|
||||
return '0 characters';
|
||||
}
|
||||
|
||||
$partLabel = $parts === 1 ? '1 SMS' : "{$parts} SMS";
|
||||
|
||||
return "{$characters} characters · {$partLabel}";
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user