Files
ladill-events/app/Services/Events/EventCommsService.php
T
isaaccladandCursor 0c5a1ddb23
Deploy Ladill Events / deploy (push) Successful in 31s
Send Events mail from platform SMTP like Invoice, not owner Bird domain.
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>
2026-07-03 17:09:05 +00:00

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));
}
}