Files
ladill-frontdesk/app/Jobs/SendCampaignMessageJob.php
T
isaaccladandCursor 1def8cf716
Deploy Ladill Frontdesk / deploy (push) Has been cancelled
Brand Frontdesk Laravel mail for hosts and report recipients.
Pass organization logo/name through ContactMessageMail for daily reports and campaigns, and drop the Ladill product footer.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-12 20:10:26 +00:00

204 lines
6.7 KiB
PHP

<?php
namespace App\Jobs;
use App\Models\Activity;
use App\Models\Campaign;
use App\Models\CampaignRecipient;
use App\Models\Organization;
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;
}
$organization = Organization::query()->where('owner_ref', $campaign->owner_ref)->first();
$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),
$organization,
);
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');
}
}