Files
isaacclad a5ebd1e414
Deploy Ladill Events / deploy (push) Successful in 48s
fix(events): point Messaging settings at suite defaults, not API keys
Mirror Care: suite platform keys count as ready for attendee email/SMS;
Account Messaging is primary, product Bird/SMS keys are advanced.
SmsService now prefers channel=suite before customer API keys.
2026-07-16 11:36:43 +00:00

209 lines
8.0 KiB
PHP

<?php
namespace App\Services\Events;
use App\Models\QrCode;
use App\Models\QrEventRegistration;
use App\Services\Billing\PlatformEmailClient;
use App\Services\Billing\PlatformSmsClient;
use App\Services\Billing\SmsService;
use App\Services\Messaging\MessagingCredentialsService;
use Illuminate\Support\Collection;
class EventCommsService
{
public const MODE_PROGRAMME = 'programme';
public const MODE_JOIN = 'join';
public const MODE_BOTH = 'both';
private const SMS_SEGMENT_CHARS = 160;
private const SMS_PRICE_GHS = 0.03;
private const EMAIL_PRICE_GHS = 0.0135;
public function __construct(
private readonly EventEmailService $email,
private readonly SmsService $sms,
private readonly MessagingCredentialsService $credentials,
private readonly EventProgrammeService $programmes,
private readonly PlatformEmailClient $platformEmail,
private readonly PlatformSmsClient $platformSms,
) {}
/** @return array{recipients: int, emails: int, sms: int, estimated_ghs: float, affordable: bool, integrations_error: ?string} */
public function preview(QrCode $event, string $mode): array
{
$regs = $this->confirmedRegistrations($event);
$emails = $regs->filter(fn ($r) => (bool) $r->attendee_email)->count();
$phones = $regs->filter(fn ($r) => (bool) $r->attendee_phone)->count();
$smsSegments = $phones * $this->estimateSmsSegments($event, $mode);
$estimated = round(($emails * self::EMAIL_PRICE_GHS) + ($smsSegments * self::SMS_PRICE_GHS), 2);
$ownerRef = (string) $event->user->public_id;
$integrationsError = $this->integrationsError($event, $mode, $emails, $phones);
return [
'recipients' => $regs->count(),
'emails' => $emails,
'sms' => $phones,
'estimated_ghs' => $estimated,
'affordable' => $integrationsError === null,
'integrations_error' => $integrationsError,
];
}
/**
* @return array{emailed: int, texted: int, skipped: int}
*/
public function send(QrCode $event, string $mode): array
{
$preview = $this->preview($event, $mode);
abort_if($preview['integrations_error'] !== null, 422, $preview['integrations_error']);
$programme = $this->programmeFor($event);
$eventName = $event->content()['name'] ?? $event->label;
$programmeUrl = $programme?->publicUrl();
$joinUrl = $this->primaryJoinUrl($event);
$ownerRef = (string) $event->user->public_id;
$organizerEmail = $event->user->email;
$organizerName = $event->user->name;
$emailed = 0;
$texted = 0;
$skipped = 0;
foreach ($this->confirmedRegistrations($event) as $reg) {
$sent = false;
if ($reg->attendee_email && in_array($mode, [self::MODE_PROGRAMME, self::MODE_BOTH], true) && $programmeUrl) {
if ($mode === self::MODE_BOTH) {
$sent = $this->email->sendJoinAndProgramme($ownerRef, $reg->attendee_email, $eventName, $joinUrl, $programmeUrl, $reg->attendee_name, $organizerEmail, $organizerName);
} else {
$sent = $this->email->sendProgrammeShare($ownerRef, $reg->attendee_email, $eventName, $programmeUrl, $reg->attendee_name, $organizerEmail, $organizerName);
}
if ($sent) {
$emailed++;
}
} elseif ($reg->attendee_email && $mode === self::MODE_JOIN && $joinUrl !== '') {
$sent = $this->email->sendJoinLink($ownerRef, $reg->attendee_email, $eventName, $joinUrl, $reg->attendee_name, $organizerEmail, $organizerName);
if ($sent) {
$emailed++;
}
}
if ($reg->attendee_phone) {
$message = $this->smsBody($reg, $eventName, $mode, $programmeUrl, $joinUrl);
if ($message !== '' && $this->sms->send($ownerRef, $reg->attendee_phone, $message)) {
$texted++;
$sent = true;
}
}
if (! $sent) {
$skipped++;
}
}
return compact('emailed', 'texted', 'skipped');
}
/** @return Collection<int, QrEventRegistration> */
private function confirmedRegistrations(QrCode $event): Collection
{
return $event->eventRegistrations()
->where('status', QrEventRegistration::STATUS_CONFIRMED)
->get();
}
private function programmeFor(QrCode $event): ?QrCode
{
$programmeId = (int) ($event->content()['programme_qr_id'] ?? 0);
if ($programmeId <= 0) {
return null;
}
return QrCode::where('id', $programmeId)
->where('user_id', $event->user_id)
->where('type', QrCode::TYPE_ITINERARY)
->first();
}
public function primaryJoinUrl(QrCode $event): string
{
foreach ((array) ($event->content()['virtual_sessions'] ?? []) as $session) {
if (! is_array($session)) {
continue;
}
$url = trim((string) ($session['join_url'] ?? ''));
if ($url !== '') {
return $url;
}
}
return '';
}
private function smsBody(QrEventRegistration $reg, string $eventName, string $mode, ?string $programmeUrl, string $joinUrl): string
{
$first = explode(' ', $reg->attendee_name ?? 'there')[0];
return match ($mode) {
self::MODE_PROGRAMME => $programmeUrl
? sprintf('Hi %s, the programme for %s: %s', $first, $eventName, $programmeUrl)
: '',
self::MODE_JOIN => $joinUrl !== ''
? sprintf('Hi %s, join %s here: %s', $first, $eventName, $joinUrl)
: '',
self::MODE_BOTH => ($programmeUrl && $joinUrl !== '')
? sprintf('Hi %s, %s — programme: %s | join: %s', $first, $eventName, $programmeUrl, $joinUrl)
: ($joinUrl !== '' ? sprintf('Hi %s, join %s: %s', $first, $eventName, $joinUrl) : ''),
default => '',
};
}
private function estimateSmsSegments(QrCode $event, string $mode): int
{
$sample = $this->smsBody(
new QrEventRegistration(['attendee_name' => 'Guest']),
$event->content()['name'] ?? $event->label,
$mode,
$this->programmeFor($event)?->publicUrl(),
$this->primaryJoinUrl($event),
);
return max(1, (int) ceil(strlen($sample) / self::SMS_SEGMENT_CHARS));
}
private function integrationsError(QrCode $event, string $mode, int $emails, int $phones): ?string
{
$ownerRef = (string) $event->user->public_id;
$credential = $this->credentials->forOwner($ownerRef);
$programmeUrl = $this->programmeFor($event)?->publicUrl();
$joinUrl = $this->primaryJoinUrl($event);
$needsEmail = $emails > 0 && (
(in_array($mode, [self::MODE_PROGRAMME, self::MODE_BOTH], true) && $programmeUrl)
|| ($mode === self::MODE_JOIN && $joinUrl !== '')
);
$needsSms = $phones > 0 && $this->smsBody(
new QrEventRegistration(['attendee_name' => 'Guest']),
$event->content()['name'] ?? $event->label,
$mode,
$programmeUrl,
$joinUrl,
) !== '';
$emailReady = $this->platformEmail->isConfigured() || $credential->hasValidBird();
$smsReady = $this->platformSms->isConfigured() || $credential->hasValidSms();
if ($needsEmail && ! $emailReady) {
return 'Email is not available yet. Bind a mailbox under Account → Messaging once platform keys are configured, or connect Bird under Advanced keys.';
}
if ($needsSms && ! $smsReady) {
return 'SMS is not available yet. Ensure platform SMS is configured on this Events instance, or connect a product SMS key under Advanced keys.';
}
return null;
}
}