Add virtual event sessions with Meet sync and platform-billed messaging.
Deploy Ladill Events / deploy (push) Successful in 41s

Events can define hybrid/virtual sessions synced to Meet rooms; SMS and email use wallet-billed platform APIs instead of Termii and Laravel mail.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
isaacclad
2026-07-01 22:08:09 +00:00
co-authored by Cursor
parent 5a8a462102
commit 06fedcfc55
18 changed files with 735 additions and 30 deletions
@@ -5,18 +5,20 @@ namespace App\Http\Controllers\Events;
use App\Http\Controllers\Controller;
use App\Models\QrCode;
use App\Models\QrEventRegistration;
use App\Notifications\EventProgrammeSharedNotification;
use App\Services\Billing\SmsService;
use App\Services\Events\EventEmailService;
use App\Support\Events\EventBadgeZpl;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Illuminate\Support\Facades\Notification;
use Illuminate\View\View;
class AttendeeController extends Controller
{
public function __construct(private readonly SmsService $sms) {}
public function __construct(
private readonly SmsService $sms,
private readonly EventEmailService $email,
) {}
public function hub(Request $request): View
{
@@ -163,13 +165,19 @@ class AttendeeController extends Controller
foreach ($recipients as $reg) {
if ($reg->attendee_email) {
Notification::route('mail', $reg->attendee_email)
->notify(new EventProgrammeSharedNotification($event, $programme, $reg->attendee_name));
$this->email->sendProgrammeShare(
(string) $event->user->public_id,
$reg->attendee_email,
$eventName,
$programmeUrl,
$reg->attendee_name,
);
$emailed++;
}
if ($reg->attendee_phone) {
$this->sms->send(
(string) $event->user->public_id,
$reg->attendee_phone,
sprintf(
'Hi %s, the programme for %s is here: %s',
+4
View File
@@ -42,6 +42,7 @@ class QrSetting extends Model
'organizer',
'registration_open',
'badge_fields',
'format',
];
}
@@ -142,6 +143,9 @@ class QrSetting extends Model
'organizer' => mb_substr(trim((string) ($input['organizer'] ?? '')), 0, 120),
'registration_open' => filter_var($input['registration_open'] ?? true, FILTER_VALIDATE_BOOL),
'badge_fields' => $badgeFields !== [] ? $badgeFields : ['Company', 'Role'],
'format' => in_array($input['format'] ?? 'in_person', ['in_person', 'virtual', 'hybrid'], true)
? ($input['format'] ?? 'in_person')
: 'in_person',
];
}
@@ -0,0 +1,57 @@
<?php
namespace App\Services\Billing;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
class PlatformEmailClient
{
private function base(): string
{
return rtrim((string) config('smtp.platform_api_url', 'https://ladill.com/api/smtp'), '/');
}
private function token(): string
{
return (string) config('smtp.platform_api_key', '');
}
public function send(string $ownerPublicId, string $to, string $subject, string $html, ?string $text = null): bool
{
if ($this->token() === '') {
return false;
}
try {
$res = Http::withToken($this->token())->acceptJson()->timeout(30)
->post($this->base().'/messages/send', array_filter([
'user' => $ownerPublicId,
'from' => config('smtp.from'),
'from_name' => config('smtp.from_name'),
'to' => $to,
'subject' => $subject,
'html' => $html,
'text' => $text,
]));
if ($res->status() === 402) {
Log::warning('Platform email send: insufficient balance', ['user' => $ownerPublicId]);
return false;
}
if ($res->failed()) {
Log::warning('Platform email send failed', ['status' => $res->status(), 'body' => $res->body()]);
return false;
}
return (bool) ($res->json('success') ?? true);
} catch (\Throwable $e) {
Log::warning('Platform email send error', ['error' => $e->getMessage()]);
return false;
}
}
}
+114
View File
@@ -0,0 +1,114 @@
<?php
namespace App\Services\Billing;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
class PlatformSmsClient
{
private function base(): string
{
return rtrim((string) config('sms.platform_api_url', 'https://ladill.com/api'), '/');
}
private function token(): string
{
return (string) config('sms.platform_api_key', '');
}
/** @return list<array<string, mixed>> */
public function services(string $ownerPublicId): array
{
if ($this->token() === '') {
return [];
}
try {
$res = Http::withToken($this->token())->acceptJson()->timeout(15)
->get($this->base().'/sms/services', ['user' => $ownerPublicId]);
if ($res->failed()) {
return [];
}
return (array) ($res->json('data') ?? []);
} catch (\Throwable $e) {
Log::warning('Platform SMS services lookup failed', ['error' => $e->getMessage()]);
return [];
}
}
public function ensureServiceId(string $ownerPublicId): ?int
{
$services = $this->services($ownerPublicId);
if ($services !== []) {
return (int) ($services[0]['id'] ?? 0) ?: null;
}
if ($this->token() === '') {
return null;
}
try {
$res = Http::withToken($this->token())->acceptJson()->timeout(15)
->post($this->base().'/sms/services', [
'user' => $ownerPublicId,
'name' => 'Ladill Events',
'brand_name' => 'Events',
]);
if ($res->failed()) {
return null;
}
return (int) ($res->json('data.id') ?? 0) ?: null;
} catch (\Throwable $e) {
Log::warning('Platform SMS service create failed', ['error' => $e->getMessage()]);
return null;
}
}
public function send(string $ownerPublicId, string $to, string $message, ?string $senderId = null): bool
{
if ($this->token() === '') {
return false;
}
$serviceId = $this->ensureServiceId($ownerPublicId);
if (! $serviceId) {
return false;
}
try {
$res = Http::withToken($this->token())->acceptJson()->timeout(30)
->post($this->base().'/sms/messages/send', array_filter([
'user' => $ownerPublicId,
'sms_service_id' => $serviceId,
'to' => $to,
'text' => $message,
'sender_id' => $senderId ?? config('sms.default_sender_id'),
]));
if ($res->status() === 402) {
Log::warning('Platform SMS send: insufficient balance', ['user' => $ownerPublicId]);
return false;
}
if ($res->failed()) {
Log::warning('Platform SMS send failed', ['status' => $res->status(), 'body' => $res->body()]);
return false;
}
return (bool) ($res->json('success') ?? true);
} catch (\Throwable $e) {
Log::warning('Platform SMS send error', ['error' => $e->getMessage()]);
return false;
}
}
}
+4 -23
View File
@@ -2,20 +2,12 @@
namespace App\Services\Billing;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
class SmsService
{
public function send(string $to, string $message): void
public function __construct(private readonly PlatformSmsClient $platform) {}
public function send(string $ownerPublicId, string $to, string $message): void
{
$apiKey = config('services.termii.api_key', '');
$senderId = config('services.termii.sender_id', config('app.name', 'LaDill'));
if ($apiKey === '') {
return;
}
$phone = preg_replace('/\D+/', '', $to);
if (strlen($phone) < 9) {
return;
@@ -27,17 +19,6 @@ class SmsService
$phone = '233'.substr($phone, 1);
}
try {
Http::timeout(10)->post('https://v3.api.termii.com/api/sms/send', [
'api_key' => $apiKey,
'to' => $phone,
'from' => $senderId,
'sms' => $message,
'type' => 'plain',
'channel' => 'dnd',
]);
} catch (\Throwable $e) {
Log::warning('SMS send failed', ['to' => $phone, 'error' => $e->getMessage()]);
}
$this->platform->send($ownerPublicId, $phone, $message);
}
}
+33
View File
@@ -0,0 +1,33 @@
<?php
namespace App\Services\Events;
use App\Services\Billing\PlatformEmailClient;
use Illuminate\Support\Facades\View;
class EventEmailService
{
public function __construct(private readonly PlatformEmailClient $platform) {}
public function sendProgrammeShare(
string $ownerPublicId,
string $to,
string $eventName,
string $programmeUrl,
?string $attendeeName = null,
): bool {
$html = View::make('mail.notifications.event-programme', [
'eventName' => $eventName,
'programmeUrl' => $programmeUrl,
'attendeeName' => $attendeeName,
])->render();
return $this->platform->send(
$ownerPublicId,
$to,
'Programme for '.$eventName,
$html,
strip_tags(str_replace(['<br>', '<br/>', '<br />'], "\n", $html)),
);
}
}
@@ -252,6 +252,11 @@ class EventRegistrationService
return;
}
$ownerPublicId = (string) $registration->qrCode?->user?->public_id;
if ($ownerPublicId === '') {
return;
}
$content = $registration->qrCode?->content() ?? [];
$eventName = $content['name'] ?? $registration->qrCode?->label ?? config('app.name');
$firstName = explode(' ', $registration->attendee_name)[0];
@@ -276,7 +281,38 @@ class EventRegistrationService
);
}
$this->sms->send($registration->attendee_phone, $message);
$joinUrl = $this->nextVirtualJoinUrl($content);
if ($joinUrl !== '') {
$message .= ' Join: '.$joinUrl;
}
$this->sms->send(
$ownerPublicId,
$registration->attendee_phone,
$message,
);
}
/** @param array<string, mixed> $content */
private function nextVirtualJoinUrl(array $content): string
{
$format = (string) ($content['format'] ?? 'in_person');
if (! in_array($format, ['virtual', 'hybrid'], true)) {
return '';
}
foreach ((array) ($content['virtual_sessions'] ?? []) as $session) {
if (! is_array($session)) {
continue;
}
$joinUrl = trim((string) ($session['join_url'] ?? ''));
if ($joinUrl !== '') {
return $joinUrl;
}
}
return '';
}
private function uniqueBadgeCode(): string
+137
View File
@@ -0,0 +1,137 @@
<?php
namespace App\Services\Meet;
use App\Models\QrCode;
use App\Models\User;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str;
class EventMeetSyncService
{
public function sync(QrCode $event, User $owner): QrCode
{
if ($event->type !== QrCode::TYPE_EVENT) {
return $event;
}
$content = $event->content();
$format = (string) ($content['format'] ?? 'in_person');
if (! in_array($format, ['virtual', 'hybrid'], true)) {
$this->cancelRemovedSessions($event, $owner, []);
return $event;
}
if ((string) config('meet.key', '') === '') {
return $event;
}
$sessions = (array) ($content['virtual_sessions'] ?? []);
$client = MeetClient::for($owner->public_id);
$synced = [];
$activeIds = [];
foreach ($sessions as $session) {
if (! is_array($session)) {
continue;
}
$sessionId = trim((string) ($session['id'] ?? ''));
if ($sessionId === '') {
$sessionId = 'vs-'.Str::lower(Str::random(12));
}
$activeIds[] = $sessionId;
$title = trim((string) ($session['title'] ?? ''));
if ($title === '') {
$synced[] = array_merge($session, ['id' => $sessionId]);
continue;
}
$roomType = in_array($session['meet_room_type'] ?? '', ['town_hall', 'webinar'], true)
? (string) $session['meet_room_type']
: 'town_hall';
$scheduledAt = $session['scheduled_at'] ?? $content['starts_at'] ?? null;
try {
$result = $client->upsertRoom([
'title' => $title,
'description' => trim((string) ($content['description'] ?? '')) ?: null,
'type' => $roomType,
'scheduled_at' => $scheduledAt,
'duration_minutes' => max(15, (int) ($session['duration_minutes'] ?? 60)),
'timezone' => config('app.timezone', 'UTC'),
'source' => [
'app' => 'events',
'entity_type' => 'virtual_session',
'entity_id' => $sessionId,
'event_id' => (string) $event->id,
],
]);
} catch (\Throwable $e) {
Log::warning('Events Meet sync failed', [
'event_id' => $event->id,
'session_id' => $sessionId,
'error' => $e->getMessage(),
]);
$synced[] = array_merge($session, ['id' => $sessionId]);
continue;
}
$room = (array) ($result['room'] ?? []);
$synced[] = array_merge($session, [
'id' => $sessionId,
'meet_room_uuid' => $room['uuid'] ?? $session['meet_room_uuid'] ?? null,
'join_url' => $result['join_url'] ?? $session['join_url'] ?? null,
]);
}
$this->cancelRemovedSessions($event, $owner, $activeIds);
$payload = (array) ($event->payload ?? []);
$payload['content'] = array_merge($content, ['virtual_sessions' => array_values($synced)]);
$event->payload = $payload;
$event->save();
return $event->fresh();
}
/** @param list<string> $keepIds */
private function cancelRemovedSessions(QrCode $event, User $owner, array $keepIds): void
{
if ((string) config('meet.key', '') === '') {
return;
}
$previous = (array) ($event->content()['virtual_sessions'] ?? []);
$client = MeetClient::for($owner->public_id);
foreach ($previous as $session) {
if (! is_array($session)) {
continue;
}
$sessionId = (string) ($session['id'] ?? '');
$roomUuid = (string) ($session['meet_room_uuid'] ?? '');
if ($sessionId === '' || in_array($sessionId, $keepIds, true) || $roomUuid === '') {
continue;
}
try {
$client->cancelRoom($roomUuid);
} catch (\Throwable $e) {
Log::warning('Events Meet cancel failed', [
'event_id' => $event->id,
'session_id' => $sessionId,
'error' => $e->getMessage(),
]);
}
}
}
}
+122
View File
@@ -0,0 +1,122 @@
<?php
namespace App\Services\Meet;
use Illuminate\Http\Client\ConnectionException;
use Illuminate\Http\Client\PendingRequest;
use Illuminate\Support\Facades\Http;
/**
* Client for the Ladill Meet service API virtual sessions linked to Events.
*/
class MeetClient
{
public function __construct(private readonly string $ownerRef) {}
public static function for(string $ownerRef): self
{
return new self($ownerRef);
}
/**
* @return array<string, mixed>|null
*/
public function findBySource(string $app, string $entityId): ?array
{
try {
$response = $this->client()->get('rooms/by-source', [
'source_app' => $app,
'entity_id' => $entityId,
'owner_ref' => $this->ownerRef,
]);
} catch (ConnectionException) {
return null;
}
if ($response->status() === 404) {
return null;
}
if ($response->failed()) {
return null;
}
return (array) $response->json();
}
/**
* @param array<string, mixed> $data
* @return array<string, mixed>
*/
public function upsertRoom(array $data): array
{
return $this->post('rooms', array_merge($data, [
'owner_ref' => $this->ownerRef,
'host_user_ref' => $data['host_user_ref'] ?? $this->ownerRef,
]));
}
/**
* @param array<string, mixed> $data
* @return array<string, mixed>
*/
public function updateRoom(string $roomUuid, array $data): array
{
return $this->patch("rooms/{$roomUuid}", array_merge($data, [
'owner_ref' => $this->ownerRef,
'host_user_ref' => $data['host_user_ref'] ?? $this->ownerRef,
]));
}
public function cancelRoom(string $roomUuid, ?string $hostUserRef = null): array
{
return $this->post("rooms/{$roomUuid}/cancel", [
'owner_ref' => $this->ownerRef,
'host_user_ref' => $hostUserRef ?? $this->ownerRef,
]);
}
private function client(): PendingRequest
{
return Http::baseUrl((string) config('meet.url'))
->withToken((string) config('meet.key'))
->acceptJson()
->asJson()
->connectTimeout(10)
->timeout(20);
}
/**
* @return array<string, mixed>
*/
private function post(string $path, array $data): array
{
return $this->request('post', $path, $data);
}
/**
* @return array<string, mixed>
*/
private function patch(string $path, array $data): array
{
return $this->request('patch', $path, $data);
}
/**
* @return array<string, mixed>
*/
private function request(string $method, string $path, array $data): array
{
try {
$response = $this->client()->{$method}($path, $data);
} catch (ConnectionException) {
throw new \RuntimeException('Could not reach the Meet service.');
}
if ($response->failed()) {
throw new \RuntimeException('Meet service error: '.($response->json('message') ?? $response->body()));
}
return (array) $response->json();
}
}
+10 -1
View File
@@ -19,6 +19,7 @@ class QrCodeManagerService
private QrWalletBillingService $billing,
private QrImageGeneratorService $imageGenerator,
private QrPayloadValidator $payloadValidator,
private \App\Services\Meet\EventMeetSyncService $meetSync,
) {}
public function walletFor(User $user): QrWallet
@@ -158,6 +159,10 @@ class QrCodeManagerService
$this->billing->debitForQrCreation($wallet, $qrCode);
$this->imageGenerator->generateAndStore($qrCode);
if ($type === QrCode::TYPE_EVENT) {
$qrCode = $this->meetSync->sync($qrCode->fresh(), $user);
}
return $qrCode->fresh(['document']);
});
}
@@ -294,6 +299,10 @@ class QrCodeManagerService
$this->imageGenerator->generateAndStore($qrCode);
}
if ($qrCode->type === QrCode::TYPE_EVENT) {
$qrCode = $this->meetSync->sync($qrCode->fresh(), $qrCode->user);
}
return $qrCode->fresh(['document']);
}
@@ -347,7 +356,7 @@ class QrCodeManagerService
QrCode::TYPE_VCARD => ['first_name', 'last_name', 'phone', 'email', 'company', 'website', 'address', 'note', 'social'],
QrCode::TYPE_BUSINESS => ['name', 'tagline', 'phone', 'email', 'website', 'address', 'hours'],
QrCode::TYPE_CHURCH => ['name', 'denomination', 'description', 'phone', 'email', 'website', 'address', 'service_times', 'org_type', 'accepts_payment', 'collection_types', 'brand_color'],
QrCode::TYPE_EVENT => ['name', 'tagline', 'description', 'location', 'starts_at', 'ends_at', 'organizer', 'website', 'brand_color', 'tiers', 'badge_fields', 'badge_size', 'registration_open'],
QrCode::TYPE_EVENT => ['name', 'tagline', 'description', 'location', 'starts_at', 'ends_at', 'organizer', 'website', 'brand_color', 'tiers', 'badge_fields', 'badge_size', 'registration_open', 'format', 'virtual_sessions'],
QrCode::TYPE_ITINERARY => ['title', 'subtitle', 'description', 'event_date', 'location', 'brand_color', 'days'],
QrCode::TYPE_DOCUMENT => ['allow_download'],
QrCode::TYPE_MENU => ['menu_title', 'sections', 'accepts_payment', 'brand_color', 'shipping_type', 'shipping_fee', 'free_shipping_above'],
+58
View File
@@ -260,6 +260,12 @@ class QrPayloadValidator
$categories = [];
}
$format = in_array($input['format'] ?? 'in_person', ['in_person', 'virtual', 'hybrid'], true)
? ($input['format'] ?? 'in_person')
: 'in_person';
$virtualSessions = $this->normalizeVirtualSessions($input['virtual_sessions'] ?? [], $format);
// Contributions always take payment; ticketing only for paid tiers; free never.
$acceptsPayment = match ($mode) {
'contributions' => true,
@@ -289,11 +295,63 @@ class QrPayloadValidator
'accepts_payment' => $acceptsPayment,
'registration_open' => filter_var($input['registration_open'] ?? true, FILTER_VALIDATE_BOOL),
'programme_qr_id' => ($pid = (int) ($input['programme_qr_id'] ?? 0)) > 0 ? $pid : null,
'format' => $format,
'virtual_sessions' => $virtualSessions,
],
'destination_url' => null,
];
}
/** @param mixed $sessions */
private function normalizeVirtualSessions(mixed $sessions, string $format): array
{
if ($format === 'in_person') {
return [];
}
if (! is_array($sessions)) {
return [];
}
$normalized = [];
foreach ($sessions as $session) {
if (! is_array($session)) {
continue;
}
$title = trim((string) ($session['title'] ?? ''));
if ($title === '') {
continue;
}
$id = trim((string) ($session['id'] ?? ''));
$roomType = in_array($session['meet_room_type'] ?? '', ['town_hall', 'webinar'], true)
? (string) $session['meet_room_type']
: 'town_hall';
$itemRefs = [];
foreach ((array) ($session['programme_item_refs'] ?? []) as $ref) {
$ref = trim((string) $ref);
if ($ref !== '') {
$itemRefs[] = mb_substr($ref, 0, 80);
}
}
$normalized[] = [
'id' => $id !== '' ? mb_substr($id, 0, 64) : null,
'title' => mb_substr($title, 0, 120),
'meet_room_type' => $roomType,
'scheduled_at' => QrDateFormatter::normalize((string) ($session['scheduled_at'] ?? '')),
'duration_minutes' => max(15, min(480, (int) ($session['duration_minutes'] ?? 60))),
'meet_room_uuid' => ($uuid = trim((string) ($session['meet_room_uuid'] ?? ''))) !== '' ? $uuid : null,
'join_url' => ($url = trim((string) ($session['join_url'] ?? ''))) !== '' ? $url : null,
'programme_item_refs' => array_values($itemRefs),
];
}
return array_values($normalized);
}
/** @param array<int, array{price: float}> $tiers */
private function eventHasPaidTier(array $tiers): bool
{