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_URL=http://216.250.14.144:3000/api/data
|
||||||
SMS_API_TIMEOUT=10
|
SMS_API_TIMEOUT=10
|
||||||
SMS_API_CONNECT_TIMEOUT=5
|
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')) {
|
if (! function_exists('format_phone')) {
|
||||||
/**
|
/**
|
||||||
* Format an 8-digit TM mobile number for display (+993 6X XX XX XX).
|
* 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'),
|
'url' => env('SMS_API_URL', 'https://sms.daragt.com/api/sms'),
|
||||||
'timeout' => (int) env('SMS_API_TIMEOUT', 10),
|
'timeout' => (int) env('SMS_API_TIMEOUT', 10),
|
||||||
'connect_timeout' => (int) env('SMS_API_CONNECT_TIMEOUT', 5),
|
'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