Add SMS bulk delay configuration and validate coupon phone function

This commit is contained in:
Mekan1206
2026-06-03 20:57:54 +05:00
parent 275ee63ffb
commit c74330cfaf
18 changed files with 1637 additions and 0 deletions

View File

@@ -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

View 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';
}

View 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;
}
}

View File

@@ -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).

View 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()]);
}
}
}

View 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
View 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);
}
}

View 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(),
]);
}
}

View 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}";
}
}

View File

@@ -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),
],
];

View 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(),
]);
}
}

View 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,
];
}
}

View File

@@ -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');
}
};

View File

@@ -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
View 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

View 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>

View 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,
]);
}
}

View 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'],
];
}
}