Add SMS bulk delay configuration and validate coupon phone function
This commit is contained in:
@@ -69,3 +69,4 @@ VITE_APP_NAME="${APP_NAME}"
|
||||
SMS_API_URL=http://216.250.14.144:3000/api/data
|
||||
SMS_API_TIMEOUT=10
|
||||
SMS_API_CONNECT_TIMEOUT=5
|
||||
SMS_BULK_DELAY_MS=300
|
||||
|
||||
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}";
|
||||
}
|
||||
}
|
||||
@@ -39,6 +39,7 @@ return [
|
||||
'url' => env('SMS_API_URL', 'https://sms.daragt.com/api/sms'),
|
||||
'timeout' => (int) env('SMS_API_TIMEOUT', 10),
|
||||
'connect_timeout' => (int) env('SMS_API_CONNECT_TIMEOUT', 5),
|
||||
'bulk_delay_ms' => (int) env('SMS_BULK_DELAY_MS', 300),
|
||||
],
|
||||
|
||||
];
|
||||
|
||||
39
database/factories/SmsCampaignFactory.php
Normal file
39
database/factories/SmsCampaignFactory.php
Normal file
@@ -0,0 +1,39 @@
|
||||
<?php
|
||||
|
||||
namespace Database\Factories;
|
||||
|
||||
use App\Models\SmsCampaign;
|
||||
use App\Models\User;
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
|
||||
/**
|
||||
* @extends Factory<SmsCampaign>
|
||||
*/
|
||||
class SmsCampaignFactory extends Factory
|
||||
{
|
||||
protected $model = SmsCampaign::class;
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function definition(): array
|
||||
{
|
||||
return [
|
||||
'message' => fake()->sentence(),
|
||||
'mode' => 'all',
|
||||
'recipient_count' => 0,
|
||||
'sent_count' => 0,
|
||||
'failed_count' => 0,
|
||||
'skipped_count' => 0,
|
||||
'created_by' => User::factory(),
|
||||
'completed_at' => null,
|
||||
];
|
||||
}
|
||||
|
||||
public function completed(): static
|
||||
{
|
||||
return $this->state(fn (array $attributes): array => [
|
||||
'completed_at' => now(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
36
database/factories/SmsSendLogFactory.php
Normal file
36
database/factories/SmsSendLogFactory.php
Normal file
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
namespace Database\Factories;
|
||||
|
||||
use App\Enums\SmsSendStatus;
|
||||
use App\Models\Coupon;
|
||||
use App\Models\SmsCampaign;
|
||||
use App\Models\SmsSendLog;
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
|
||||
/**
|
||||
* @extends Factory<SmsSendLog>
|
||||
*/
|
||||
class SmsSendLogFactory extends Factory
|
||||
{
|
||||
protected $model = SmsSendLog::class;
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function definition(): array
|
||||
{
|
||||
$campaign = SmsCampaign::factory()->create();
|
||||
$coupon = Coupon::factory()->create();
|
||||
|
||||
return [
|
||||
'sms_campaign_id' => $campaign->id,
|
||||
'coupon_id' => $coupon->id,
|
||||
'phone' => $coupon->phone,
|
||||
'message' => $campaign->message,
|
||||
'status' => SmsSendStatus::Pending,
|
||||
'error_message' => null,
|
||||
'attempted_at' => null,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('sms_campaigns', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->text('message');
|
||||
$table->string('mode');
|
||||
$table->unsignedInteger('recipient_count')->default(0);
|
||||
$table->unsignedInteger('sent_count')->default(0);
|
||||
$table->unsignedInteger('failed_count')->default(0);
|
||||
$table->unsignedInteger('skipped_count')->default(0);
|
||||
$table->string('laravel_batch_id')->nullable();
|
||||
$table->foreignId('created_by')->constrained('users')->cascadeOnDelete();
|
||||
$table->timestamp('completed_at')->nullable();
|
||||
$table->timestamps();
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('sms_campaigns');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('sms_send_logs', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('sms_campaign_id')->constrained()->cascadeOnDelete();
|
||||
$table->foreignId('coupon_id')->nullable()->constrained()->nullOnDelete();
|
||||
$table->string('phone');
|
||||
$table->text('message');
|
||||
$table->string('status');
|
||||
$table->text('error_message')->nullable();
|
||||
$table->timestamp('attempted_at')->nullable();
|
||||
$table->timestamps();
|
||||
|
||||
$table->index(['sms_campaign_id', 'status']);
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('sms_send_logs');
|
||||
}
|
||||
};
|
||||
500
resources/codes/codes.txt
Normal file
500
resources/codes/codes.txt
Normal file
@@ -0,0 +1,500 @@
|
||||
1_3VKL
|
||||
2_MCPW
|
||||
3_7PMX
|
||||
4_TZT9
|
||||
5_0C7Y
|
||||
6_WIVJ
|
||||
7_SGAJ
|
||||
8_H4U8
|
||||
9_NS9X
|
||||
10_812A
|
||||
11_EK7K
|
||||
12_TE33
|
||||
13_XWNL
|
||||
14_1NTQ
|
||||
15_CDLW
|
||||
16_8JUV
|
||||
17_JPDJ
|
||||
18_U21E
|
||||
19_EBRZ
|
||||
20_UPF8
|
||||
21_PKLI
|
||||
22_21DT
|
||||
23_BGR4
|
||||
24_USZQ
|
||||
25_8GQ3
|
||||
26_CVRV
|
||||
27_XNJG
|
||||
28_THB6
|
||||
29_PMCG
|
||||
30_MFT5
|
||||
31_WDP9
|
||||
32_Q30T
|
||||
33_VOOK
|
||||
34_0BTS
|
||||
35_TULN
|
||||
36_4VV9
|
||||
37_K2F4
|
||||
38_LMBA
|
||||
39_85X7
|
||||
40_4PUS
|
||||
41_9OLE
|
||||
42_11YM
|
||||
43_WRI9
|
||||
44_F2M0
|
||||
45_VW7L
|
||||
46_LZ79
|
||||
47_7PY5
|
||||
48_R80B
|
||||
49_M2QS
|
||||
50_MNC6
|
||||
51_91MD
|
||||
52_TE3C
|
||||
53_AF5L
|
||||
54_EF1S
|
||||
55_8EKV
|
||||
56_KPYO
|
||||
57_HSW6
|
||||
58_PBEN
|
||||
59_F3NM
|
||||
60_GA7C
|
||||
61_BBFM
|
||||
62_BP3J
|
||||
63_FC7K
|
||||
64_K7YK
|
||||
65_WIPQ
|
||||
66_ZJ6A
|
||||
67_U0GY
|
||||
68_FSMM
|
||||
69_YDEG
|
||||
70_98JY
|
||||
71_3UF4
|
||||
72_WCLV
|
||||
73_89K6
|
||||
74_HZFK
|
||||
75_UC84
|
||||
76_M55S
|
||||
77_LAG2
|
||||
78_NZ5P
|
||||
79_DKIG
|
||||
80_DP4T
|
||||
81_UYOC
|
||||
82_ILY0
|
||||
83_GR2J
|
||||
84_KM6B
|
||||
85_KVI3
|
||||
86_1SSF
|
||||
87_NNPW
|
||||
88_I1OD
|
||||
89_GDMS
|
||||
90_8QUH
|
||||
91_8JMX
|
||||
92_KHVE
|
||||
93_WM7B
|
||||
94_L4O6
|
||||
95_QQ49
|
||||
96_4JC1
|
||||
97_TDI2
|
||||
98_1TM0
|
||||
99_5E3R
|
||||
100_KP4E
|
||||
101_9O8Y
|
||||
102_TXAO
|
||||
103_ITQ4
|
||||
104_NZAZ
|
||||
105_RIWG
|
||||
106_OTA9
|
||||
107_1CW5
|
||||
108_0IF4
|
||||
109_GURI
|
||||
110_JUM2
|
||||
111_WDMH
|
||||
112_93G4
|
||||
113_HWVP
|
||||
114_OZTR
|
||||
115_9C6X
|
||||
116_HC8N
|
||||
117_293T
|
||||
118_GL0T
|
||||
119_N0DT
|
||||
120_QZ4D
|
||||
121_EG3S
|
||||
122_W4TI
|
||||
123_3RW6
|
||||
124_KT1X
|
||||
125_RKPO
|
||||
126_R8ZS
|
||||
127_XZS1
|
||||
128_U41S
|
||||
129_H8VI
|
||||
130_IZD7
|
||||
131_3FJA
|
||||
132_EWCU
|
||||
133_DIEM
|
||||
134_TO2S
|
||||
135_49NX
|
||||
136_CEEL
|
||||
137_Y9MG
|
||||
138_YO87
|
||||
139_3081
|
||||
140_ZECX
|
||||
141_NMH9
|
||||
142_8W3Z
|
||||
143_KNV9
|
||||
144_LZU9
|
||||
145_36HY
|
||||
146_SF8N
|
||||
147_IZGI
|
||||
148_Q8HH
|
||||
149_5PQK
|
||||
150_BJM8
|
||||
151_6LFK
|
||||
152_XTBN
|
||||
153_4D37
|
||||
154_2DAI
|
||||
155_5AZ7
|
||||
156_UQ0A
|
||||
157_XPRJ
|
||||
158_EHRC
|
||||
159_JC06
|
||||
160_LA9J
|
||||
161_B5SZ
|
||||
162_W5W3
|
||||
163_OF1U
|
||||
164_GC5D
|
||||
165_8WK8
|
||||
166_XH10
|
||||
167_R7H0
|
||||
168_GWOT
|
||||
169_8M0N
|
||||
170_BRAA
|
||||
171_PL05
|
||||
172_7DCY
|
||||
173_Q8E7
|
||||
174_J7QE
|
||||
175_RPKN
|
||||
176_7UKB
|
||||
177_927Y
|
||||
178_M4OZ
|
||||
179_VF3C
|
||||
180_0LCS
|
||||
181_CU8W
|
||||
182_HCVL
|
||||
183_EF1H
|
||||
184_AOG1
|
||||
185_K3QX
|
||||
186_DY9P
|
||||
187_X658
|
||||
188_LZZW
|
||||
189_959S
|
||||
190_O577
|
||||
191_X1NU
|
||||
192_KU9L
|
||||
193_QFUC
|
||||
194_CB48
|
||||
195_ZR2G
|
||||
196_UIFK
|
||||
197_BL2X
|
||||
198_L896
|
||||
199_QIU6
|
||||
200_N1XM
|
||||
201_IIZW
|
||||
202_H1W8
|
||||
203_KUAT
|
||||
204_HM4E
|
||||
205_BWLL
|
||||
206_RP4T
|
||||
207_LSU6
|
||||
208_EXBW
|
||||
209_I74Q
|
||||
210_QUKQ
|
||||
211_66SO
|
||||
212_PSOP
|
||||
213_4HJU
|
||||
214_IPYE
|
||||
215_I1W7
|
||||
216_X8ZV
|
||||
217_YEZB
|
||||
218_MPMJ
|
||||
219_A5GT
|
||||
220_1HJT
|
||||
221_8RWP
|
||||
222_URNI
|
||||
223_G90D
|
||||
224_BXEN
|
||||
225_QYEV
|
||||
226_K20E
|
||||
227_6X2P
|
||||
228_JI0S
|
||||
229_PTDE
|
||||
230_LLLG
|
||||
231_3IWX
|
||||
232_RHNR
|
||||
233_F8XQ
|
||||
234_SXRC
|
||||
235_Y4LK
|
||||
236_1E5V
|
||||
237_CO7W
|
||||
238_U7Y1
|
||||
239_8QXP
|
||||
240_0WO7
|
||||
241_BS3V
|
||||
242_E5T2
|
||||
243_0ETP
|
||||
244_7MIU
|
||||
245_25DW
|
||||
246_OCPS
|
||||
247_XBP9
|
||||
248_QDA1
|
||||
249_2CXJ
|
||||
250_STYT
|
||||
251_ZNLZ
|
||||
252_Z0NJ
|
||||
253_PE45
|
||||
254_8K30
|
||||
255_WK9E
|
||||
256_N12C
|
||||
257_NHU8
|
||||
258_JM5W
|
||||
259_QAP6
|
||||
260_O73L
|
||||
261_3TFN
|
||||
262_23I2
|
||||
263_6ZT4
|
||||
264_AV12
|
||||
265_9S0I
|
||||
266_0WIB
|
||||
267_MW1C
|
||||
268_HL55
|
||||
269_0L3E
|
||||
270_JMKI
|
||||
271_U4EJ
|
||||
272_YLN2
|
||||
273_MR75
|
||||
274_8K7S
|
||||
275_JPVV
|
||||
276_SMQ8
|
||||
277_935M
|
||||
278_H7SA
|
||||
279_Z0WG
|
||||
280_P58E
|
||||
281_RUQL
|
||||
282_Q84V
|
||||
283_YH6B
|
||||
284_F5YL
|
||||
285_S8FP
|
||||
286_FW42
|
||||
287_LACT
|
||||
288_UG5P
|
||||
289_82KV
|
||||
290_MGJN
|
||||
291_3BBA
|
||||
292_W62R
|
||||
293_2D61
|
||||
294_E6Q8
|
||||
295_X43E
|
||||
296_OWL9
|
||||
297_H79G
|
||||
298_S1DH
|
||||
299_JRKD
|
||||
300_KG68
|
||||
301_9ER6
|
||||
302_1MVY
|
||||
303_3ST4
|
||||
304_LMTD
|
||||
305_SB8C
|
||||
306_J42I
|
||||
307_EIUX
|
||||
308_U5BL
|
||||
309_RZ2E
|
||||
310_20L2
|
||||
311_Y2EY
|
||||
312_YEUU
|
||||
313_HBY3
|
||||
314_SNPT
|
||||
315_7W31
|
||||
316_03QM
|
||||
317_WTA6
|
||||
318_2Z9V
|
||||
319_4C2F
|
||||
320_5HX6
|
||||
321_X2H7
|
||||
322_OHYV
|
||||
323_ORN8
|
||||
324_CA5O
|
||||
325_AJFS
|
||||
326_EGN1
|
||||
327_VBR0
|
||||
328_VO3N
|
||||
329_8QP5
|
||||
330_0A19
|
||||
331_R1BO
|
||||
332_FKH9
|
||||
333_KKUO
|
||||
334_L7UR
|
||||
335_LSKK
|
||||
336_ML7S
|
||||
337_MA9Y
|
||||
338_GGCE
|
||||
339_0NL9
|
||||
340_DXMF
|
||||
341_8I2Z
|
||||
342_KWO0
|
||||
343_K2XY
|
||||
344_Z8FN
|
||||
345_R04V
|
||||
346_IGGL
|
||||
347_3YL3
|
||||
348_E3W3
|
||||
349_FU44
|
||||
350_0X7B
|
||||
351_GI6E
|
||||
352_O3LC
|
||||
353_WER4
|
||||
354_E1WM
|
||||
355_7HPZ
|
||||
356_SRL0
|
||||
357_C0IG
|
||||
358_T47P
|
||||
359_3ADP
|
||||
360_MWPI
|
||||
361_XKW0
|
||||
362_KR6N
|
||||
363_U53I
|
||||
364_64LP
|
||||
365_N2BN
|
||||
366_16M0
|
||||
367_FDS4
|
||||
368_RY2M
|
||||
369_PPCM
|
||||
370_X7IX
|
||||
371_6XUN
|
||||
372_6IDF
|
||||
373_YL7O
|
||||
374_OTPM
|
||||
375_B086
|
||||
376_RLMV
|
||||
377_UNQN
|
||||
378_NX6Q
|
||||
379_335Q
|
||||
380_OB1Z
|
||||
381_ULKQ
|
||||
382_QHL2
|
||||
383_ZFFS
|
||||
384_NU7Z
|
||||
385_EM9Z
|
||||
386_GOM7
|
||||
387_R2WL
|
||||
388_J350
|
||||
389_KMA3
|
||||
390_8MPR
|
||||
391_PFZB
|
||||
392_P8W4
|
||||
393_E36O
|
||||
394_FA0T
|
||||
395_VLC4
|
||||
396_49FF
|
||||
397_0RMD
|
||||
398_FHCI
|
||||
399_ZT6A
|
||||
400_EE45
|
||||
401_4ULP
|
||||
402_2HHJ
|
||||
403_33D8
|
||||
404_QQBW
|
||||
405_KM9U
|
||||
406_5YQ2
|
||||
407_KAN2
|
||||
408_81RU
|
||||
409_8H5W
|
||||
410_R2FH
|
||||
411_YHG1
|
||||
412_W15M
|
||||
413_9MGE
|
||||
414_BL23
|
||||
415_44MX
|
||||
416_0XN1
|
||||
417_N1SE
|
||||
418_EV2G
|
||||
419_FDMW
|
||||
420_L599
|
||||
421_O8I8
|
||||
422_SDRS
|
||||
423_5FLL
|
||||
424_IKYD
|
||||
425_ZGU2
|
||||
426_AJM6
|
||||
427_PL0Q
|
||||
428_BQN7
|
||||
429_AFGG
|
||||
430_8XWJ
|
||||
431_916E
|
||||
432_QG5T
|
||||
433_PU29
|
||||
434_SKS9
|
||||
435_BVS6
|
||||
436_ROUE
|
||||
437_Z772
|
||||
438_KYU5
|
||||
439_4KY9
|
||||
440_8CMS
|
||||
441_QIYG
|
||||
442_ZTBY
|
||||
443_PPS1
|
||||
444_T5EX
|
||||
445_2FH9
|
||||
446_ULQX
|
||||
447_BI9M
|
||||
448_KSL5
|
||||
449_852B
|
||||
450_399J
|
||||
451_0PQS
|
||||
452_S75P
|
||||
453_N4FI
|
||||
454_KZ81
|
||||
455_4LAG
|
||||
456_O33N
|
||||
457_C9HS
|
||||
458_J28X
|
||||
459_0M05
|
||||
460_KIHC
|
||||
461_NX86
|
||||
462_F4IB
|
||||
463_KGXK
|
||||
464_C9LJ
|
||||
465_L2VB
|
||||
466_HU09
|
||||
467_GAFU
|
||||
468_B4AA
|
||||
469_6O5G
|
||||
470_8FB1
|
||||
471_I9KU
|
||||
472_I7MX
|
||||
473_L5OM
|
||||
474_OBTX
|
||||
475_T6YS
|
||||
476_6KSS
|
||||
477_6H6A
|
||||
478_W6IU
|
||||
479_PR66
|
||||
480_6D8V
|
||||
481_8MSK
|
||||
482_VV5S
|
||||
483_VO57
|
||||
484_EZJL
|
||||
485_8H2G
|
||||
486_RKBS
|
||||
487_NSJ9
|
||||
488_J316
|
||||
489_JQCW
|
||||
490_9IGH
|
||||
491_5HPW
|
||||
492_37JS
|
||||
493_Y4M6
|
||||
494_Z9UH
|
||||
495_8Q2V
|
||||
496_M3L4
|
||||
497_LVE9
|
||||
498_8FZ5
|
||||
499_2NK3
|
||||
500_BW2M
|
||||
25
resources/views/filament/pages/send-sms.blade.php
Normal file
25
resources/views/filament/pages/send-sms.blade.php
Normal file
@@ -0,0 +1,25 @@
|
||||
<x-filament-panels::page>
|
||||
@if ($activeCampaignId)
|
||||
<div
|
||||
wire:poll.2s="refreshCampaignProgress"
|
||||
class="mb-6 rounded-xl border border-primary-200 bg-primary-50 p-4 dark:border-primary-800 dark:bg-primary-950"
|
||||
>
|
||||
<div class="mb-2 flex items-center justify-between gap-4">
|
||||
<p class="text-sm font-medium text-primary-900 dark:text-primary-100">
|
||||
Sending messages…
|
||||
</p>
|
||||
<p class="text-sm text-primary-700 dark:text-primary-300">
|
||||
{{ $campaignProgressLabel }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="h-2 overflow-hidden rounded-full bg-primary-200 dark:bg-primary-900">
|
||||
<div
|
||||
class="h-full rounded-full bg-primary-600 transition-all duration-300 dark:bg-primary-400"
|
||||
style="width: {{ $campaignProgressPercent }}%"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
{{ $this->content }}
|
||||
</x-filament-panels::page>
|
||||
154
tests/Feature/SmsBroadcastServiceTest.php
Normal file
154
tests/Feature/SmsBroadcastServiceTest.php
Normal file
@@ -0,0 +1,154 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
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 App\Services\SmsBroadcastService;
|
||||
use Illuminate\Foundation\Testing\LazilyRefreshDatabase;
|
||||
use Illuminate\Support\Facades\Bus;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Queue;
|
||||
use Tests\TestCase;
|
||||
|
||||
class SmsBroadcastServiceTest extends TestCase
|
||||
{
|
||||
use LazilyRefreshDatabase;
|
||||
|
||||
private SmsBroadcastService $service;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
Http::fake([
|
||||
config('services.sms.url') => Http::response('ok', 200),
|
||||
]);
|
||||
|
||||
$this->service = app(SmsBroadcastService::class);
|
||||
}
|
||||
|
||||
public function test_resolve_recipients_for_all_excluding_ids(): void
|
||||
{
|
||||
$included = Coupon::factory()->count(2)->create();
|
||||
$excluded = Coupon::factory()->create();
|
||||
|
||||
$recipients = $this->service->resolveRecipients(
|
||||
sendToAll: true,
|
||||
excludedIds: [$excluded->id],
|
||||
);
|
||||
|
||||
$this->assertCount(2, $recipients);
|
||||
$this->assertTrue($recipients->pluck('id')->contains($included->first()->id));
|
||||
$this->assertFalse($recipients->pluck('id')->contains($excluded->id));
|
||||
}
|
||||
|
||||
public function test_resolve_recipients_for_selected_only(): void
|
||||
{
|
||||
$selected = Coupon::factory()->create();
|
||||
Coupon::factory()->create();
|
||||
|
||||
$recipients = $this->service->resolveRecipients(
|
||||
sendToAll: false,
|
||||
selectedIds: [$selected->id],
|
||||
);
|
||||
|
||||
$this->assertCount(1, $recipients);
|
||||
$this->assertSame($selected->id, $recipients->first()->id);
|
||||
}
|
||||
|
||||
public function test_start_campaign_queues_chain_and_creates_logs(): void
|
||||
{
|
||||
Bus::fake();
|
||||
|
||||
$admin = User::factory()->create();
|
||||
$coupons = Coupon::factory()->count(2)->create();
|
||||
|
||||
$campaign = $this->service->startCampaign(
|
||||
admin: $admin,
|
||||
message: 'Test broadcast',
|
||||
sendToAll: false,
|
||||
selectedIds: $coupons->pluck('id')->all(),
|
||||
);
|
||||
|
||||
$this->assertSame(2, $campaign->recipient_count);
|
||||
$this->assertDatabaseCount(SmsSendLog::class, 2);
|
||||
$this->assertDatabaseHas(SmsSendLog::class, [
|
||||
'sms_campaign_id' => $campaign->id,
|
||||
'status' => SmsSendStatus::Pending->value,
|
||||
]);
|
||||
|
||||
Bus::assertChained([
|
||||
SendSmsToCouponJob::class,
|
||||
SendSmsToCouponJob::class,
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_invalid_phone_is_skipped_without_queue_job(): void
|
||||
{
|
||||
Bus::fake();
|
||||
|
||||
$admin = User::factory()->create();
|
||||
$invalid = Coupon::factory()->create(['phone' => '81234567']);
|
||||
$valid = Coupon::factory()->create();
|
||||
|
||||
$campaign = $this->service->startCampaign(
|
||||
admin: $admin,
|
||||
message: 'Test',
|
||||
sendToAll: false,
|
||||
selectedIds: [$invalid->id, $valid->id],
|
||||
);
|
||||
|
||||
$this->assertSame(2, $campaign->recipient_count);
|
||||
$this->assertSame(1, $campaign->skipped_count);
|
||||
|
||||
$this->assertDatabaseHas(SmsSendLog::class, [
|
||||
'coupon_id' => $invalid->id,
|
||||
'status' => SmsSendStatus::SkippedInvalid->value,
|
||||
]);
|
||||
|
||||
Bus::assertChained([
|
||||
SendSmsToCouponJob::class,
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_send_job_marks_success_and_completes_campaign(): void
|
||||
{
|
||||
Queue::fake();
|
||||
|
||||
$admin = User::factory()->create();
|
||||
$coupon = Coupon::factory()->create(['phone' => '61929248']);
|
||||
|
||||
$campaign = SmsCampaign::factory()->create([
|
||||
'message' => 'Queued hello',
|
||||
'recipient_count' => 1,
|
||||
'created_by' => $admin->id,
|
||||
]);
|
||||
|
||||
SmsSendLog::factory()->create([
|
||||
'sms_campaign_id' => $campaign->id,
|
||||
'coupon_id' => $coupon->id,
|
||||
'phone' => $coupon->phone,
|
||||
'message' => $campaign->message,
|
||||
'status' => SmsSendStatus::Pending,
|
||||
]);
|
||||
|
||||
$job = new SendSmsToCouponJob($campaign, $coupon);
|
||||
$job->handle();
|
||||
|
||||
Http::assertSentCount(1);
|
||||
|
||||
$campaign->refresh();
|
||||
|
||||
$this->assertSame(1, $campaign->sent_count);
|
||||
$this->assertNotNull($campaign->completed_at);
|
||||
$this->assertDatabaseHas(SmsSendLog::class, [
|
||||
'sms_campaign_id' => $campaign->id,
|
||||
'status' => SmsSendStatus::Sent->value,
|
||||
]);
|
||||
}
|
||||
}
|
||||
72
tests/Unit/SmsMessageAnalyzerTest.php
Normal file
72
tests/Unit/SmsMessageAnalyzerTest.php
Normal file
@@ -0,0 +1,72 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Unit;
|
||||
|
||||
use App\Services\SmsMessageAnalyzer;
|
||||
use PHPUnit\Framework\Attributes\DataProvider;
|
||||
use Tests\TestCase;
|
||||
|
||||
class SmsMessageAnalyzerTest extends TestCase
|
||||
{
|
||||
private SmsMessageAnalyzer $analyzer;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
$this->analyzer = new SmsMessageAnalyzer;
|
||||
}
|
||||
|
||||
public function test_empty_message_has_zero_parts(): void
|
||||
{
|
||||
$this->assertSame(0, $this->analyzer->estimatedParts(''));
|
||||
}
|
||||
|
||||
public function test_gsm_single_part_message(): void
|
||||
{
|
||||
$message = str_repeat('A', 160);
|
||||
|
||||
$this->assertTrue($this->analyzer->isGsm7($message));
|
||||
$this->assertSame(1, $this->analyzer->estimatedParts($message));
|
||||
}
|
||||
|
||||
public function test_gsm_multi_part_message(): void
|
||||
{
|
||||
$message = str_repeat('A', 161);
|
||||
|
||||
$this->assertSame(2, $this->analyzer->estimatedParts($message));
|
||||
}
|
||||
|
||||
public function test_unicode_single_part_message(): void
|
||||
{
|
||||
$message = 'Привет';
|
||||
|
||||
$this->assertFalse($this->analyzer->isGsm7($message));
|
||||
$this->assertSame(1, $this->analyzer->estimatedParts($message));
|
||||
}
|
||||
|
||||
public function test_unicode_multi_part_message(): void
|
||||
{
|
||||
$message = str_repeat('Я', 71);
|
||||
|
||||
$this->assertSame(2, $this->analyzer->estimatedParts($message));
|
||||
}
|
||||
|
||||
#[DataProvider('summaryProvider')]
|
||||
public function test_summary(string $message, string $expectedSubstring): void
|
||||
{
|
||||
$this->assertStringContainsString($expectedSubstring, $this->analyzer->summary($message));
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, array{0: string, 1: string}>
|
||||
*/
|
||||
public static function summaryProvider(): array
|
||||
{
|
||||
return [
|
||||
'empty' => ['', '0 characters'],
|
||||
'gsm' => ['Hello', '5 characters · 1 SMS'],
|
||||
'unicode' => ['你好', '2 characters · 1 SMS'],
|
||||
];
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user