Deploy Ladill Frontdesk / deploy (push) Successful in 45s
NotificationDispatcher sends via customer relay and skips Frontdesk wallet debit when tenant keys are configured. Co-authored-by: Cursor <cursoragent@cursor.com>
200 lines
6.6 KiB
PHP
200 lines
6.6 KiB
PHP
<?php
|
|
|
|
namespace App\Jobs;
|
|
|
|
use App\Models\Activity;
|
|
use App\Models\Campaign;
|
|
use App\Models\CampaignRecipient;
|
|
use App\Models\User;
|
|
use App\Services\Campaigns\CampaignBillingService;
|
|
use App\Services\Campaigns\CampaignMessageService;
|
|
use App\Services\Campaigns\CampaignPricingService;
|
|
use App\Services\Comms\EmailService;
|
|
use App\Services\Comms\SmsService;
|
|
use Illuminate\Contracts\Queue\ShouldQueue;
|
|
use Illuminate\Foundation\Queue\Queueable;
|
|
use Illuminate\Support\Facades\DB;
|
|
|
|
/**
|
|
* Campaign delivery job — orphaned until Campaign / CampaignRecipient models exist.
|
|
* Wire to CustomerSmsClient / CustomerEmailClient + MessagingCredentialsService when campaigns ship.
|
|
*/
|
|
class SendCampaignMessageJob implements ShouldQueue
|
|
{
|
|
use Queueable;
|
|
|
|
public function __construct(public int $recipientId)
|
|
{
|
|
}
|
|
|
|
public function handle(
|
|
EmailService $email,
|
|
SmsService $sms,
|
|
CampaignPricingService $pricing,
|
|
CampaignBillingService $billing,
|
|
CampaignMessageService $messages,
|
|
): void {
|
|
$recipient = CampaignRecipient::query()->with('campaign')->find($this->recipientId);
|
|
|
|
if (! $recipient || ! $recipient->campaign || $recipient->status !== CampaignRecipient::STATUS_PENDING) {
|
|
return;
|
|
}
|
|
|
|
$campaign = $recipient->campaign;
|
|
|
|
if (! in_array($campaign->status, [Campaign::STATUS_QUEUED, Campaign::STATUS_SENDING], true)) {
|
|
return;
|
|
}
|
|
|
|
$body = $messages->personalize($campaign->body, $recipient->recipient_name);
|
|
$subject = $messages->personalize((string) ($campaign->subject ?? ''), $recipient->recipient_name);
|
|
$costMinor = $pricing->recipientCostMinor($campaign, $campaign->body);
|
|
|
|
if (! $billing->canAfford($campaign->owner_ref, $costMinor)) {
|
|
$this->markRecipient($recipient, CampaignRecipient::STATUS_SKIPPED, 0, 'Insufficient wallet balance.');
|
|
|
|
return;
|
|
}
|
|
|
|
$sent = $campaign->channel === Campaign::CHANNEL_SMS
|
|
? $sms->send((string) $recipient->recipient_phone, $body)
|
|
: $email->send(
|
|
(string) $recipient->recipient_email,
|
|
$subject !== '' ? $subject : 'Message from '.config('app.name'),
|
|
$body,
|
|
$this->senderName($campaign),
|
|
$this->senderEmail($campaign),
|
|
);
|
|
|
|
if (! $sent) {
|
|
$this->markRecipient($recipient, CampaignRecipient::STATUS_FAILED, 0, 'Delivery could not be confirmed.');
|
|
|
|
return;
|
|
}
|
|
|
|
$charged = $billing->charge(
|
|
$campaign->owner_ref,
|
|
$costMinor,
|
|
$campaign->channel,
|
|
$campaign->id,
|
|
$recipient->id,
|
|
'CRM campaign "'.$campaign->name.'" to '.$recipient->recipient_name,
|
|
);
|
|
|
|
$this->markRecipient(
|
|
$recipient,
|
|
CampaignRecipient::STATUS_SENT,
|
|
$charged ? $costMinor : 0,
|
|
null,
|
|
now(),
|
|
);
|
|
|
|
$this->logActivity($campaign, $recipient, $subject, $body);
|
|
}
|
|
|
|
private function markRecipient(
|
|
CampaignRecipient $recipient,
|
|
string $status,
|
|
int $costMinor,
|
|
?string $error,
|
|
?\DateTimeInterface $sentAt = null,
|
|
): void {
|
|
$recipient->forceFill([
|
|
'status' => $status,
|
|
'cost_minor' => $costMinor,
|
|
'error_message' => $error,
|
|
'sent_at' => $sentAt,
|
|
])->save();
|
|
|
|
$this->refreshCampaignCounters($recipient->campaign()->first());
|
|
}
|
|
|
|
private function refreshCampaignCounters(?Campaign $campaign): void
|
|
{
|
|
if (! $campaign) {
|
|
return;
|
|
}
|
|
|
|
DB::transaction(function () use ($campaign) {
|
|
$campaign = Campaign::query()->lockForUpdate()->find($campaign->id);
|
|
|
|
if (! $campaign) {
|
|
return;
|
|
}
|
|
|
|
$counts = $campaign->recipients()
|
|
->selectRaw("
|
|
SUM(CASE WHEN status = ? THEN 1 ELSE 0 END) as sent_count,
|
|
SUM(CASE WHEN status = ? THEN 1 ELSE 0 END) as failed_count,
|
|
SUM(CASE WHEN status = ? THEN 1 ELSE 0 END) as skipped_count,
|
|
SUM(cost_minor) as actual_cost_minor
|
|
", [
|
|
CampaignRecipient::STATUS_SENT,
|
|
CampaignRecipient::STATUS_FAILED,
|
|
CampaignRecipient::STATUS_SKIPPED,
|
|
])
|
|
->first();
|
|
|
|
$pending = $campaign->recipients()->where('status', CampaignRecipient::STATUS_PENDING)->count();
|
|
|
|
$campaign->forceFill([
|
|
'sent_count' => (int) ($counts->sent_count ?? 0),
|
|
'failed_count' => (int) ($counts->failed_count ?? 0),
|
|
'skipped_count' => (int) ($counts->skipped_count ?? 0),
|
|
'actual_cost_minor' => (int) ($counts->actual_cost_minor ?? 0),
|
|
]);
|
|
|
|
if ($pending === 0 && in_array($campaign->status, [Campaign::STATUS_QUEUED, Campaign::STATUS_SENDING], true)) {
|
|
$campaign->status = Campaign::STATUS_COMPLETED;
|
|
$campaign->completed_at = now();
|
|
} elseif ($campaign->status === Campaign::STATUS_QUEUED) {
|
|
$campaign->status = Campaign::STATUS_SENDING;
|
|
}
|
|
|
|
$campaign->save();
|
|
});
|
|
}
|
|
|
|
private function logActivity(Campaign $campaign, CampaignRecipient $recipient, string $subject, string $body): void
|
|
{
|
|
$title = $campaign->channel === Campaign::CHANNEL_SMS
|
|
? 'Campaign SMS: '.$campaign->name
|
|
: 'Campaign email: '.($subject !== '' ? $subject : $campaign->name);
|
|
|
|
$payload = [
|
|
'owner_ref' => $campaign->owner_ref,
|
|
'type' => $campaign->channel,
|
|
'direction' => 'out',
|
|
'title' => $title,
|
|
'body' => $body,
|
|
'completed_at' => now(),
|
|
];
|
|
|
|
if ($recipient->customer_id) {
|
|
Activity::create([
|
|
...$payload,
|
|
'subject_type' => 'contact',
|
|
'subject_id' => $recipient->customer_id,
|
|
]);
|
|
}
|
|
|
|
if ($recipient->lead_id) {
|
|
Activity::create([
|
|
...$payload,
|
|
'subject_type' => 'lead',
|
|
'subject_id' => $recipient->lead_id,
|
|
]);
|
|
}
|
|
}
|
|
|
|
private function senderName(Campaign $campaign): ?string
|
|
{
|
|
return User::query()->where('public_id', $campaign->owner_ref)->value('name');
|
|
}
|
|
|
|
private function senderEmail(Campaign $campaign): ?string
|
|
{
|
|
return User::query()->where('public_id', $campaign->owner_ref)->value('email');
|
|
}
|
|
}
|