diff --git a/.env.example b/.env.example index 233076d..bde3a65 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/app/Enums/SmsSendStatus.php b/app/Enums/SmsSendStatus.php new file mode 100644 index 0000000..eb64e4a --- /dev/null +++ b/app/Enums/SmsSendStatus.php @@ -0,0 +1,11 @@ +|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 + */ + protected function getViewData(): array + { + return [ + 'campaignProgressPercent' => $this->getCampaignProgressPercent(), + 'campaignProgressLabel' => $this->getCampaignProgressLabel(), + ]; + } + + /** + * @return array + */ + 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 $values + * @return array + */ + 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; + } +} diff --git a/app/Helpers/helpers.php b/app/Helpers/helpers.php index 5a15b43..76e7e86 100644 --- a/app/Helpers/helpers.php +++ b/app/Helpers/helpers.php @@ -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). diff --git a/app/Jobs/SendSmsToCouponJob.php b/app/Jobs/SendSmsToCouponJob.php new file mode 100644 index 0000000..6819ace --- /dev/null +++ b/app/Jobs/SendSmsToCouponJob.php @@ -0,0 +1,81 @@ +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()]); + } + } +} diff --git a/app/Models/SmsCampaign.php b/app/Models/SmsCampaign.php new file mode 100644 index 0000000..615e356 --- /dev/null +++ b/app/Models/SmsCampaign.php @@ -0,0 +1,66 @@ + */ + use HasFactory; + + protected $fillable = [ + 'message', + 'mode', + 'recipient_count', + 'sent_count', + 'failed_count', + 'skipped_count', + 'laravel_batch_id', + 'created_by', + 'completed_at', + ]; + + /** + * @return array + */ + 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)); + } +} diff --git a/app/Models/SmsSendLog.php b/app/Models/SmsSendLog.php new file mode 100644 index 0000000..bd0e375 --- /dev/null +++ b/app/Models/SmsSendLog.php @@ -0,0 +1,46 @@ + */ + use HasFactory; + + protected $fillable = [ + 'sms_campaign_id', + 'coupon_id', + 'phone', + 'message', + 'status', + 'error_message', + 'attempted_at', + ]; + + /** + * @return array + */ + 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); + } +} diff --git a/app/Services/SmsBroadcastService.php b/app/Services/SmsBroadcastService.php new file mode 100644 index 0000000..b5f15d4 --- /dev/null +++ b/app/Services/SmsBroadcastService.php @@ -0,0 +1,97 @@ + $selectedIds + * @param array $excludedIds + * @return Collection + */ + 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(), + ]); + } +} diff --git a/app/Services/SmsMessageAnalyzer.php b/app/Services/SmsMessageAnalyzer.php new file mode 100644 index 0000000..64ddb3e --- /dev/null +++ b/app/Services/SmsMessageAnalyzer.php @@ -0,0 +1,65 @@ +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}"; + } +} diff --git a/config/services.php b/config/services.php index d664c73..ded25a7 100644 --- a/config/services.php +++ b/config/services.php @@ -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), ], ]; diff --git a/database/factories/SmsCampaignFactory.php b/database/factories/SmsCampaignFactory.php new file mode 100644 index 0000000..17afe53 --- /dev/null +++ b/database/factories/SmsCampaignFactory.php @@ -0,0 +1,39 @@ + + */ +class SmsCampaignFactory extends Factory +{ + protected $model = SmsCampaign::class; + + /** + * @return array + */ + 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(), + ]); + } +} diff --git a/database/factories/SmsSendLogFactory.php b/database/factories/SmsSendLogFactory.php new file mode 100644 index 0000000..fc6a27b --- /dev/null +++ b/database/factories/SmsSendLogFactory.php @@ -0,0 +1,36 @@ + + */ +class SmsSendLogFactory extends Factory +{ + protected $model = SmsSendLog::class; + + /** + * @return array + */ + 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, + ]; + } +} diff --git a/database/migrations/2026_06_03_195148_create_sms_campaigns_table.php b/database/migrations/2026_06_03_195148_create_sms_campaigns_table.php new file mode 100644 index 0000000..7cf4604 --- /dev/null +++ b/database/migrations/2026_06_03_195148_create_sms_campaigns_table.php @@ -0,0 +1,30 @@ +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'); + } +}; diff --git a/database/migrations/2026_06_03_195149_create_sms_send_logs_table.php b/database/migrations/2026_06_03_195149_create_sms_send_logs_table.php new file mode 100644 index 0000000..624e5c0 --- /dev/null +++ b/database/migrations/2026_06_03_195149_create_sms_send_logs_table.php @@ -0,0 +1,30 @@ +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'); + } +}; diff --git a/resources/codes/codes.txt b/resources/codes/codes.txt new file mode 100644 index 0000000..ce2bb2c --- /dev/null +++ b/resources/codes/codes.txt @@ -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 diff --git a/resources/views/filament/pages/send-sms.blade.php b/resources/views/filament/pages/send-sms.blade.php new file mode 100644 index 0000000..fc8b0a5 --- /dev/null +++ b/resources/views/filament/pages/send-sms.blade.php @@ -0,0 +1,25 @@ + + @if ($activeCampaignId) +
+
+

+ Sending messages… +

+

+ {{ $campaignProgressLabel }} +

+
+
+
+
+
+ @endif + + {{ $this->content }} +
diff --git a/tests/Feature/SmsBroadcastServiceTest.php b/tests/Feature/SmsBroadcastServiceTest.php new file mode 100644 index 0000000..9ba1e58 --- /dev/null +++ b/tests/Feature/SmsBroadcastServiceTest.php @@ -0,0 +1,154 @@ + 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, + ]); + } +} diff --git a/tests/Unit/SmsMessageAnalyzerTest.php b/tests/Unit/SmsMessageAnalyzerTest.php new file mode 100644 index 0000000..928304e --- /dev/null +++ b/tests/Unit/SmsMessageAnalyzerTest.php @@ -0,0 +1,72 @@ +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 + */ + public static function summaryProvider(): array + { + return [ + 'empty' => ['', '0 characters'], + 'gsm' => ['Hello', '5 characters · 1 SMS'], + 'unicode' => ['你好', '2 characters · 1 SMS'], + ]; + } +}