Deploy Ladill Events / deploy (push) Successful in 31s
Use EventMailer with MAIL_FROM and organiser Reply-To instead of the Bird API, which rewrote the From address onto the account's verified domain (e.g. climp.me). Redesign the speaker invitation to use the shared Events email layout. Co-authored-by: Cursor <cursoragent@cursor.com>
171 lines
6.3 KiB
PHP
171 lines
6.3 KiB
PHP
<?php
|
|
|
|
namespace App\Services\Events;
|
|
|
|
use App\Models\QrCode;
|
|
use App\Models\QrEventRegistration;
|
|
use App\Services\Billing\BillingClient;
|
|
use App\Services\Billing\SmsService;
|
|
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 BillingClient $billing,
|
|
private readonly EventProgrammeService $programmes,
|
|
) {}
|
|
|
|
/** @return array{recipients: int, emails: int, sms: int, estimated_ghs: float, affordable: bool} */
|
|
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;
|
|
$affordable = $estimated <= 0 || $this->billing->canAfford($ownerRef, (int) round($estimated * 100));
|
|
|
|
return [
|
|
'recipients' => $regs->count(),
|
|
'emails' => $emails,
|
|
'sms' => $phones,
|
|
'estimated_ghs' => $estimated,
|
|
'affordable' => $affordable,
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @return array{emailed: int, texted: int, skipped: int}
|
|
*/
|
|
public function send(QrCode $event, string $mode): array
|
|
{
|
|
$preview = $this->preview($event, $mode);
|
|
abort_unless($preview['affordable'], 402, 'Insufficient wallet balance for this send.');
|
|
|
|
$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));
|
|
}
|
|
}
|