Complete Events–Meet integration phases 2–5.
Deploy Ladill Events / deploy (push) Successful in 41s

Programme sync to Meet rooms, wallet-billed mass comms, webhook lifecycle updates, and registration access bridge with join-window gating on public pages.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
isaacclad
2026-07-01 22:31:45 +00:00
co-authored by Cursor
parent 06fedcfc55
commit 05a6be7efe
20 changed files with 798 additions and 115 deletions
+1
View File
@@ -41,6 +41,7 @@ IDENTITY_API_KEY_EVENTS=
MEET_API_URL=https://meet.ladill.com/api/service/v1
MEET_API_KEY_EVENTS=
MEET_WEBHOOK_SECRET_EVENTS=
SMS_PLATFORM_API_URL=https://ladill.com/api
SMS_API_KEY_EVENTS=
@@ -0,0 +1,39 @@
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Services\Meet\MeetLifecycleService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class MeetWebhookController extends Controller
{
public function __invoke(Request $request, MeetLifecycleService $lifecycle): JsonResponse
{
$secret = (string) config('meet.webhook_secret', '');
if ($secret === '') {
return response()->json(['error' => 'Webhook not configured.'], 503);
}
$signature = (string) $request->header('X-Ladill-Meet-Signature', '');
$body = $request->getContent();
$expected = hash_hmac('sha256', $body, $secret);
if ($signature === '' || ! hash_equals($expected, $signature)) {
return response()->json(['error' => 'Invalid signature.'], 401);
}
/** @var array<string, mixed> $payload */
$payload = $request->json()->all();
$event = (string) ($payload['event'] ?? '');
if ($event === '') {
return response()->json(['error' => 'Missing event.'], 422);
}
$lifecycle->handle($event, $payload);
return response()->json(['ok' => true]);
}
}
@@ -5,19 +5,19 @@ namespace App\Http\Controllers\Events;
use App\Http\Controllers\Controller;
use App\Models\QrCode;
use App\Models\QrEventRegistration;
use App\Services\Billing\SmsService;
use App\Services\Events\EventEmailService;
use App\Services\Events\EventCommsService;
use App\Support\Events\EventBadgeZpl;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Illuminate\Validation\Rule;
use Illuminate\View\View;
class AttendeeController extends Controller
{
public function __construct(
private readonly SmsService $sms,
private readonly EventEmailService $email,
private readonly EventCommsService $comms,
) {}
public function hub(Request $request): View
@@ -133,7 +133,24 @@ class AttendeeController extends Controller
'revenue' => $event->eventRegistrations()->where('status', QrEventRegistration::STATUS_CONFIRMED)->sum('amount_minor') / 100,
];
$content = $event->content();
$format = (string) ($content['format'] ?? 'in_person');
$virtualSessions = collect((array) ($content['virtual_sessions'] ?? []))->filter(fn ($s) => is_array($s));
if (in_array($format, ['virtual', 'hybrid'], true) && $virtualSessions->isNotEmpty()) {
$stats['virtual_sessions'] = $virtualSessions->count();
$stats['virtual_live'] = $virtualSessions->where('meet_status', 'live')->count();
$stats['virtual_ended'] = $virtualSessions->where('meet_status', 'ended')->count();
$stats['virtual_recordings'] = $virtualSessions->filter(fn ($s) => ! empty($s['recording_url']))->count();
}
$programme = $this->programmeFor($event);
$joinUrl = $this->comms->primaryJoinUrl($event);
$commsModes = array_values(array_filter([
$programme ? EventCommsService::MODE_PROGRAMME : null,
$joinUrl !== '' ? EventCommsService::MODE_JOIN : null,
($programme && $joinUrl !== '') ? EventCommsService::MODE_BOTH : null,
]));
return view('events.attendees', [
'qrCode' => $event,
@@ -141,60 +158,64 @@ class AttendeeController extends Controller
'registrations' => $registrations,
'stats' => $stats,
'programme' => $programme,
'commsModes' => $commsModes,
'defaultCommsMode' => $commsModes[0] ?? EventCommsService::MODE_PROGRAMME,
]);
}
public function shareProgramme(QrCode $event): RedirectResponse
public function previewComms(QrCode $event, Request $request): JsonResponse
{
$this->authorize('view', $event);
abort_unless($event->type === QrCode::TYPE_EVENT, 404);
$programme = $this->programmeFor($event);
if (! $programme) {
return back()->with('error', 'No programme outline is attached to this event yet.');
$mode = (string) $request->query('mode', EventCommsService::MODE_PROGRAMME);
abort_unless(in_array($mode, [EventCommsService::MODE_PROGRAMME, EventCommsService::MODE_JOIN, EventCommsService::MODE_BOTH], true), 422);
return response()->json($this->comms->preview($event, $mode));
}
$recipients = $event->eventRegistrations()
->where('status', QrEventRegistration::STATUS_CONFIRMED)
->get();
public function shareComms(QrCode $event, Request $request): RedirectResponse
{
$this->authorize('view', $event);
abort_unless($event->type === QrCode::TYPE_EVENT, 404);
$eventName = $event->content()['name'] ?? $event->label;
$programmeUrl = $programme->publicUrl();
$emailed = 0;
$texted = 0;
$validated = $request->validate([
'mode' => ['required', Rule::in([
EventCommsService::MODE_PROGRAMME,
EventCommsService::MODE_JOIN,
EventCommsService::MODE_BOTH,
])],
]);
foreach ($recipients as $reg) {
if ($reg->attendee_email) {
$this->email->sendProgrammeShare(
(string) $event->user->public_id,
$reg->attendee_email,
$eventName,
$programmeUrl,
$reg->attendee_name,
);
$emailed++;
$preview = $this->comms->preview($event, $validated['mode']);
if (! $preview['affordable']) {
return back()->with('error', 'Insufficient wallet balance. Add funds in Billing before sending.');
}
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',
explode(' ', $reg->attendee_name ?? 'there')[0],
$eventName,
$programmeUrl
)
);
$texted++;
}
if ($preview['recipients'] === 0) {
return back()->with('error', 'No confirmed attendees to message yet.');
}
if ($emailed === 0 && $texted === 0) {
return back()->with('error', 'No confirmed attendees with contact details to share with yet.');
$result = $this->comms->send($event, $validated['mode']);
if ($result['emailed'] === 0 && $result['texted'] === 0) {
return back()->with('error', 'No messages were sent — check that attendees have email or phone on file.');
}
return back()->with('success', "Programme shared — {$emailed} email(s) and {$texted} SMS sent.");
return back()->with('success', sprintf(
'Sent — %d email(s), %d SMS, %d skipped.',
$result['emailed'],
$result['texted'],
$result['skipped'],
));
}
/** @deprecated Use shareComms with mode=programme */
public function shareProgramme(QrCode $event): RedirectResponse
{
request()->merge(['mode' => EventCommsService::MODE_PROGRAMME]);
return $this->shareComms($event, request());
}
private function programmeFor(QrCode $event): ?QrCode
+168
View File
@@ -0,0 +1,168 @@
<?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;
$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);
} else {
$sent = $this->email->sendProgrammeShare($ownerRef, $reg->attendee_email, $eventName, $programmeUrl, $reg->attendee_name);
}
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);
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));
}
}
+64 -6
View File
@@ -16,16 +16,74 @@ class EventEmailService
string $programmeUrl,
?string $attendeeName = null,
): bool {
$html = View::make('mail.notifications.event-programme', [
'eventName' => $eventName,
'programmeUrl' => $programmeUrl,
'attendeeName' => $attendeeName,
])->render();
return $this->send(
$ownerPublicId,
$to,
'Programme for '.$eventName,
'mail.notifications.event-programme',
compact('eventName', 'programmeUrl', 'attendeeName'),
);
}
public function sendJoinLink(
string $ownerPublicId,
string $to,
string $eventName,
string $joinUrl,
?string $attendeeName = null,
): bool {
return $this->send(
$ownerPublicId,
$to,
'Join '.$eventName,
'mail.notifications.event-join',
compact('eventName', 'joinUrl', 'attendeeName'),
);
}
public function sendJoinAndProgramme(
string $ownerPublicId,
string $to,
string $eventName,
string $joinUrl,
string $programmeUrl,
?string $attendeeName = null,
): bool {
return $this->send(
$ownerPublicId,
$to,
$eventName.' — programme & join link',
'mail.notifications.event-join-programme',
compact('eventName', 'joinUrl', 'programmeUrl', 'attendeeName'),
);
}
public function sendRegistrationConfirmation(
string $ownerPublicId,
string $to,
string $eventName,
string $badgeCode,
?string $joinUrl = null,
?string $attendeeName = null,
): bool {
return $this->send(
$ownerPublicId,
$to,
'Registration confirmed — '.$eventName,
'mail.notifications.event-registration-confirmed',
compact('eventName', 'badgeCode', 'joinUrl', 'attendeeName'),
);
}
/** @param array<string, mixed> $viewData */
private function send(string $ownerPublicId, string $to, string $subject, string $view, array $viewData): bool
{
$html = View::make($view, $viewData)->render();
return $this->platform->send(
$ownerPublicId,
$to,
'Programme for '.$eventName,
$subject,
$html,
strip_tags(str_replace(['<br>', '<br/>', '<br />'], "\n", $html)),
);
@@ -0,0 +1,74 @@
<?php
namespace App\Services\Events;
use App\Models\QrCode;
use Illuminate\Support\Collection;
class EventProgrammeService
{
/** @return array{title: string, days: list<array<string, mixed>>}|null */
public function snapshot(?QrCode $programme): ?array
{
if (! $programme || $programme->type !== QrCode::TYPE_ITINERARY) {
return null;
}
$content = $programme->content();
return [
'title' => (string) ($content['title'] ?? $programme->label),
'subtitle' => (string) ($content['subtitle'] ?? ''),
'location' => (string) ($content['location'] ?? ''),
'days' => array_values((array) ($content['days'] ?? [])),
];
}
/** @param list<string> $itemRefs */
public function itemsForRefs(?array $snapshot, array $itemRefs): array
{
if (! $snapshot) {
return [];
}
$refs = array_flip($itemRefs);
$matched = [];
foreach ((array) ($snapshot['days'] ?? []) as $day) {
foreach ((array) ($day['items'] ?? []) as $item) {
$ref = (string) ($item['ref'] ?? '');
if ($ref !== '' && isset($refs[$ref])) {
$matched[] = $item;
}
}
}
return $matched;
}
/** @param list<array<string, mixed>> $items */
public function hostsFromItems(array $items): array
{
return collect($items)
->pluck('host')
->filter(fn ($host) => is_string($host) && trim($host) !== '')
->map(fn ($host) => trim($host))
->unique()
->values()
->all();
}
/** @return Collection<int, QrCode> */
public function eventsLinkedToProgramme(QrCode $programme): Collection
{
if ($programme->type !== QrCode::TYPE_ITINERARY) {
return collect();
}
return QrCode::query()
->where('user_id', $programme->user_id)
->where('type', QrCode::TYPE_EVENT)
->get()
->filter(fn (QrCode $event) => (int) ($event->content()['programme_qr_id'] ?? 0) === $programme->id);
}
}
@@ -7,6 +7,8 @@ use App\Models\QrEventRegistration;
use App\Services\Billing\BillingClient;
use App\Services\Billing\PaystackService;
use App\Services\Billing\SmsService;
use App\Services\Events\EventEmailService;
use App\Services\Meet\EventMeetAccessService;
use App\Services\Pay\PayClient;
use App\Support\LadillLink;
use Illuminate\Support\Str;
@@ -19,6 +21,8 @@ class EventRegistrationService
private PaystackService $paystack,
private BillingClient $billing,
private SmsService $sms,
private EventEmailService $email,
private EventMeetAccessService $meetAccess,
) {}
/**
@@ -248,9 +252,7 @@ class EventRegistrationService
private function notifyConfirmed(QrEventRegistration $registration): void
{
if (! $registration->attendee_phone) {
return;
}
$registration->loadMissing('qrCode.user');
$ownerPublicId = (string) $registration->qrCode?->user?->public_id;
if ($ownerPublicId === '') {
@@ -260,7 +262,20 @@ class EventRegistrationService
$content = $registration->qrCode?->content() ?? [];
$eventName = $content['name'] ?? $registration->qrCode?->label ?? config('app.name');
$firstName = explode(' ', $registration->attendee_name)[0];
$joinUrl = $this->nextVirtualJoinUrl($content);
if ($registration->attendee_email) {
$this->email->sendRegistrationConfirmation(
$ownerPublicId,
$registration->attendee_email,
$eventName,
$registration->badge_code,
$joinUrl !== '' ? $joinUrl : null,
$registration->attendee_name,
);
}
if ($registration->attendee_phone) {
if (($content['mode'] ?? 'ticketing') === 'contributions') {
$message = sprintf(
'Hi %s, thank you for your %s of %s %s to %s. Ref: %s',
@@ -281,7 +296,6 @@ class EventRegistrationService
);
}
$joinUrl = $this->nextVirtualJoinUrl($content);
if ($joinUrl !== '') {
$message .= ' Join: '.$joinUrl;
}
@@ -293,6 +307,9 @@ class EventRegistrationService
);
}
$this->meetAccess->syncRegistration($registration->fresh());
}
/** @param array<string, mixed> $content */
private function nextVirtualJoinUrl(array $content): string
{
@@ -0,0 +1,75 @@
<?php
namespace App\Services\Meet;
use App\Models\QrCode;
use App\Models\QrEventRegistration;
use Illuminate\Support\Facades\Log;
class EventMeetAccessService
{
public function syncRegistration(QrEventRegistration $registration): void
{
$event = $registration->qrCode;
if (! $event || $event->type !== QrCode::TYPE_EVENT) {
return;
}
$format = (string) ($event->content()['format'] ?? 'in_person');
if (! in_array($format, ['virtual', 'hybrid'], true)) {
return;
}
if ((string) config('meet.key', '') === '' || ! $registration->attendee_email) {
return;
}
$ownerRef = (string) $event->user->public_id;
$client = MeetClient::for($ownerRef);
foreach ((array) ($event->content()['virtual_sessions'] ?? []) as $session) {
if (! is_array($session)) {
continue;
}
$roomUuid = trim((string) ($session['meet_room_uuid'] ?? ''));
if ($roomUuid === '') {
continue;
}
try {
$client->updateRoom($roomUuid, [
'invite_emails' => [$registration->attendee_email],
]);
} catch (\Throwable $e) {
Log::warning('Events Meet invite failed', [
'registration_id' => $registration->id,
'room_uuid' => $roomUuid,
'error' => $e->getMessage(),
]);
}
}
$metadata = (array) ($registration->metadata ?? []);
$metadata['meet_invited_at'] = now()->toIso8601String();
$registration->update(['metadata' => $metadata]);
}
public function isJoinWindowOpen(array $session, int $minutesBefore = 15): bool
{
$scheduled = trim((string) ($session['scheduled_at'] ?? ''));
if ($scheduled === '') {
return true;
}
try {
$start = \Carbon\Carbon::parse($scheduled);
} catch (\Throwable) {
return true;
}
$duration = max(15, (int) ($session['duration_minutes'] ?? 60));
return now()->between($start->copy()->subMinutes($minutesBefore), $start->copy()->addMinutes($duration));
}
}
@@ -4,11 +4,14 @@ namespace App\Services\Meet;
use App\Models\QrCode;
use App\Models\User;
use App\Services\Events\EventProgrammeService;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str;
class EventMeetSyncService
{
public function __construct(private readonly EventProgrammeService $programmes) {}
public function sync(QrCode $event, User $owner): QrCode
{
if ($event->type !== QrCode::TYPE_EVENT) {
@@ -28,6 +31,7 @@ class EventMeetSyncService
return $event;
}
$programmeSnapshot = $this->programmeSnapshotFor($event);
$sessions = (array) ($content['virtual_sessions'] ?? []);
$client = MeetClient::for($owner->public_id);
$synced = [];
@@ -56,6 +60,23 @@ class EventMeetSyncService
: 'town_hall';
$scheduledAt = $session['scheduled_at'] ?? $content['starts_at'] ?? null;
$itemRefs = (array) ($session['programme_item_refs'] ?? []);
$linkedItems = $this->programmes->itemsForRefs($programmeSnapshot, $itemRefs);
$inviteEmails = $this->inviteEmailsFromHosts($this->programmes->hostsFromItems($linkedItems));
$settings = [
'programme_snapshot' => $programmeSnapshot,
'linked_programme_items' => $linkedItems,
];
if ($roomType === 'town_hall' && $linkedItems !== []) {
$settings['panels'] = [[
'id' => 'panel-'.$sessionId,
'name' => $title,
'speaker_refs' => [],
]];
$settings['panel_discussions'] = true;
}
try {
$result = $client->upsertRoom([
@@ -65,6 +86,8 @@ class EventMeetSyncService
'scheduled_at' => $scheduledAt,
'duration_minutes' => max(15, (int) ($session['duration_minutes'] ?? 60)),
'timezone' => config('app.timezone', 'UTC'),
'settings' => $settings,
'invite_emails' => $inviteEmails,
'source' => [
'app' => 'events',
'entity_type' => 'virtual_session',
@@ -101,6 +124,38 @@ class EventMeetSyncService
return $event->fresh();
}
public function syncEventsForProgramme(QrCode $programme, User $owner): void
{
foreach ($this->programmes->eventsLinkedToProgramme($programme) as $event) {
$this->sync($event->fresh(), $owner);
}
}
/** @return array<string, mixed>|null */
private function programmeSnapshotFor(QrCode $event): ?array
{
$programmeId = (int) ($event->content()['programme_qr_id'] ?? 0);
if ($programmeId <= 0) {
return null;
}
$programme = QrCode::where('id', $programmeId)
->where('user_id', $event->user_id)
->where('type', QrCode::TYPE_ITINERARY)
->first();
return $this->programmes->snapshot($programme);
}
/** @param list<string> $hosts */
private function inviteEmailsFromHosts(array $hosts): array
{
return collect($hosts)
->filter(fn ($host) => filter_var($host, FILTER_VALIDATE_EMAIL))
->values()
->all();
}
/** @param list<string> $keepIds */
private function cancelRemovedSessions(QrCode $event, User $owner, array $keepIds): void
{
@@ -0,0 +1,71 @@
<?php
namespace App\Services\Meet;
use App\Models\QrCode;
use Illuminate\Support\Facades\Log;
class MeetLifecycleService
{
/** @param array<string, mixed> $payload */
public function handle(string $event, array $payload): void
{
$room = (array) ($payload['room'] ?? []);
$source = (array) ($room['source'] ?? []);
if (($source['app'] ?? '') !== 'events') {
return;
}
$eventId = (int) ($source['event_id'] ?? 0);
$sessionId = (string) ($source['entity_id'] ?? '');
if ($eventId <= 0 || $sessionId === '') {
return;
}
$qrCode = QrCode::find($eventId);
if (! $qrCode || $qrCode->type !== QrCode::TYPE_EVENT) {
return;
}
$content = $qrCode->content();
$sessions = (array) ($content['virtual_sessions'] ?? []);
$updated = false;
foreach ($sessions as $index => $session) {
if (! is_array($session) || ($session['id'] ?? '') !== $sessionId) {
continue;
}
$sessions[$index] = array_merge($session, $this->patchForEvent($event, $payload, $room));
$updated = true;
break;
}
if (! $updated) {
return;
}
$payloadData = (array) ($qrCode->payload ?? []);
$payloadData['content'] = array_merge($content, ['virtual_sessions' => array_values($sessions)]);
$qrCode->update(['payload' => $payloadData]);
Log::info('Events Meet lifecycle applied', ['event' => $event, 'event_id' => $eventId, 'session_id' => $sessionId]);
}
/** @return array<string, mixed> */
private function patchForEvent(string $event, array $payload, array $room): array
{
return match ($event) {
'meeting.started' => ['meet_status' => 'live', 'meet_live_at' => now()->toIso8601String()],
'meeting.ended' => ['meet_status' => 'ended', 'meet_ended_at' => now()->toIso8601String()],
'meeting.cancelled' => ['meet_status' => 'cancelled'],
'recording.ready' => [
'recording_url' => (string) (($payload['recording']['download_url'] ?? '') ?: ''),
'recording_ready_at' => now()->toIso8601String(),
],
default => [],
};
}
}
+4
View File
@@ -303,6 +303,10 @@ class QrCodeManagerService
$qrCode = $this->meetSync->sync($qrCode->fresh(), $qrCode->user);
}
if ($qrCode->type === QrCode::TYPE_ITINERARY) {
$this->meetSync->syncEventsForProgramme($qrCode->fresh(), $qrCode->user);
}
return $qrCode->fresh(['document']);
}
+3 -2
View File
@@ -374,12 +374,12 @@ class QrPayloadValidator
// Days: [{ label, date, items: [{ time, title, description, location, host }] }]
$days = [];
foreach ((array) ($input['days'] ?? []) as $day) {
foreach ((array) ($input['days'] ?? []) as $dayIndex => $day) {
if (! is_array($day)) {
continue;
}
$items = [];
foreach ((array) ($day['items'] ?? []) as $item) {
foreach ((array) ($day['items'] ?? []) as $itemIndex => $item) {
if (! is_array($item)) {
continue;
}
@@ -388,6 +388,7 @@ class QrPayloadValidator
continue;
}
$items[] = [
'ref' => 'day'.$dayIndex.'-item'.$itemIndex,
'time' => mb_substr(trim((string) ($item['time'] ?? '')), 0, 40),
'title' => mb_substr($itemTitle, 0, 140),
'description' => mb_substr(trim((string) ($item['description'] ?? '')), 0, 400),
+1
View File
@@ -4,5 +4,6 @@ return [
'url' => rtrim((string) env('MEET_API_URL', 'https://meet.ladill.com/api/service/v1'), '/'),
'key' => env('MEET_API_KEY_EVENTS'),
'webhook_secret' => env('MEET_WEBHOOK_SECRET_EVENTS'),
];
+56 -14
View File
@@ -32,42 +32,74 @@
@endunless
</div>
{{-- Share programme outline --}}
@if($programme)
<div class="flex flex-col gap-3 rounded-2xl border border-indigo-100 bg-indigo-50/60 p-4 sm:flex-row sm:items-center sm:justify-between">
{{-- Mass comms --}}
@if(!empty($commsModes))
<div class="rounded-2xl border border-indigo-100 bg-indigo-50/60 p-4"
x-data="{
mode: @js($defaultCommsMode),
preview: null,
busy: false,
async loadPreview() {
const r = await fetch(@js(route('events.attendees.comms-preview', $qrCode)).replace(/&amp;/g, '&') + '?mode=' + encodeURIComponent(this.mode), {
headers: { 'Accept': 'application/json', 'X-Requested-With': 'XMLHttpRequest' },
});
if (r.ok) this.preview = await r.json();
},
}"
x-init="loadPreview()">
<div class="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<div class="flex items-start gap-3">
<span class="flex h-9 w-9 shrink-0 items-center justify-center rounded-xl bg-indigo-100 text-indigo-600">
<svg class="h-5 w-5" fill="none" stroke="currentColor" stroke-width="1.8" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M9 12h3.75M9 15h3.75M9 18h3.75m3 .75H18a2.25 2.25 0 0 0 2.25-2.25V6.108c0-1.135-.845-2.098-1.976-2.192a48.424 48.424 0 0 0-1.123-.08m-5.801 0c-.065.21-.1.433-.1.664 0 .414.336.75.75.75h4.5a.75.75 0 0 0 .75-.75 2.25 2.25 0 0 0-.1-.664m-5.8 0A2.251 2.251 0 0 1 13.5 2.25H15c1.012 0 1.867.668 2.15 1.586m-5.8 0c-.376.023-.75.05-1.124.08C9.095 4.01 8.25 4.973 8.25 6.108V8.25m0 0H4.875c-.621 0-1.125.504-1.125 1.125v11.25c0 .621.504 1.125 1.125 1.125h9.75c.621 0 1.125-.504 1.125-1.125V9.375c0-.621-.504-1.125-1.125-1.125H8.25Z"/></svg>
<svg class="h-5 w-5" fill="none" stroke="currentColor" stroke-width="1.8" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M21.75 6.75v10.5a2.25 2.25 0 0 1-2.25 2.25h-15a2.25 2.25 0 0 1-2.25-2.25V6.75m19.5 0A2.25 2.25 0 0 0 19.5 4.5h-15a2.25 2.25 0 0 0-2.25 2.25m19.5 0v.243a2.25 2.25 0 0 1-1.07 1.916l-7.5 4.615a2.25 2.25 0 0 1-2.36 0L3.32 8.91a2.25 2.25 0 0 1-1.07-1.916V6.75"/></svg>
</span>
<div>
<p class="text-sm font-semibold text-slate-900">Programme outline</p>
<p class="text-xs text-slate-500">Email &amp; text the <span class="font-medium text-slate-600">{{ $programme->label }}</span> programme link to all confirmed attendees.</p>
<p class="text-sm font-semibold text-slate-900">Message attendees</p>
<p class="text-xs text-slate-500">Send programme links, virtual join links, or both. SMS and email are billed from your wallet.</p>
<template x-if="preview">
<p class="mt-2 text-xs text-slate-600">
<span x-text="preview.recipients"></span> recipients ·
est. GHS <span x-text="preview.estimated_ghs.toFixed(2)"></span>
<span x-show="!preview.affordable" class="font-semibold text-red-600"> insufficient balance</span>
</p>
</template>
</div>
</div>
<form method="POST" action="{{ route('events.attendees.share-programme', $qrCode) }}"
x-data="{ busy: false }"
<form method="POST" action="{{ route('events.attendees.share-comms', $qrCode) }}"
@submit="async ($event) => {
if (busy) return;
if (! await $store.ladillConfirm.ask({
title: 'Send programme?',
message: 'Send the programme outline to all confirmed attendees by email and SMS?',
title: 'Send messages?',
message: 'Send to all confirmed attendees by email and SMS where available?',
confirmLabel: 'Send',
variant: 'primary',
})) { $event.preventDefault(); return; }
busy = true;
}"
class="shrink-0">
class="flex w-full shrink-0 flex-col gap-2 sm:w-auto sm:flex-row sm:items-center">
@csrf
<button type="submit" :disabled="busy"
<select name="mode" x-model="mode" @change="loadPreview()"
class="rounded-xl border border-slate-200 bg-white px-3 py-2 text-sm text-slate-700 focus:border-indigo-400 focus:outline-none">
@if(in_array(\App\Services\Events\EventCommsService::MODE_PROGRAMME, $commsModes))
<option value="programme">Programme only</option>
@endif
@if(in_array(\App\Services\Events\EventCommsService::MODE_JOIN, $commsModes))
<option value="join">Join link only</option>
@endif
@if(in_array(\App\Services\Events\EventCommsService::MODE_BOTH, $commsModes))
<option value="both">Programme &amp; join link</option>
@endif
</select>
<button type="submit" :disabled="busy || (preview && !preview.affordable)"
class="btn-primary w-full sm:w-auto">
<span x-text="busy ? 'Sending…' : 'Share with attendees'">Share with attendees</span>
<span x-text="busy ? 'Sending…' : 'Send to attendees'">Send to attendees</span>
</button>
</form>
</div>
</div>
@endif
{{-- Stats --}}
<div class="grid {{ $isContribution ? 'grid-cols-2' : 'grid-cols-3' }} gap-3">
<div class="grid gap-3 {{ isset($stats['virtual_sessions']) ? ($isContribution ? 'grid-cols-2 sm:grid-cols-4' : 'grid-cols-2 sm:grid-cols-5') : ($isContribution ? 'grid-cols-2' : 'grid-cols-3') }}">
<div class="rounded-2xl border border-slate-200 bg-white p-4">
<p class="text-2xl font-bold text-slate-900">{{ number_format($stats['total']) }}</p>
<p class="text-xs text-slate-500">{{ $isContribution ? 'Contributions' : 'Registered' }}</p>
@@ -82,6 +114,16 @@
<p class="text-2xl font-bold text-slate-900">GHS {{ number_format($stats['revenue'], 2) }}</p>
<p class="text-xs text-slate-500">{{ $isContribution ? 'Total raised' : 'Revenue' }}</p>
</div>
@if(isset($stats['virtual_sessions']))
<div class="rounded-2xl border border-slate-200 bg-white p-4">
<p class="text-2xl font-bold text-slate-900">{{ number_format($stats['virtual_live']) }}/{{ number_format($stats['virtual_sessions']) }}</p>
<p class="text-xs text-slate-500">Sessions live</p>
</div>
<div class="rounded-2xl border border-slate-200 bg-white p-4">
<p class="text-2xl font-bold text-slate-900">{{ number_format($stats['virtual_recordings']) }}</p>
<p class="text-xs text-slate-500">Recordings ready</p>
</div>
@endif
</div>
{{-- Toolbar --}}
@@ -0,0 +1,13 @@
<!DOCTYPE html>
<html>
<body style="font-family: sans-serif; color: #1e293b; line-height: 1.5;">
@if($attendeeName)
<p>Hi {{ $attendeeName }},</p>
@endif
<p>Here is everything you need for <strong>{{ $eventName }}</strong>:</p>
<ul>
<li><a href="{{ $programmeUrl }}">View programme</a></li>
<li><a href="{{ $joinUrl }}">Join virtual session</a></li>
</ul>
</body>
</html>
@@ -0,0 +1,10 @@
<!DOCTYPE html>
<html>
<body style="font-family: sans-serif; color: #1e293b; line-height: 1.5;">
@if($attendeeName)
<p>Hi {{ $attendeeName }},</p>
@endif
<p>Your virtual session for <strong>{{ $eventName }}</strong> is ready.</p>
<p><a href="{{ $joinUrl }}">Join the session</a></p>
</body>
</html>
@@ -0,0 +1,13 @@
<!DOCTYPE html>
<html>
<body style="font-family: sans-serif; color: #1e293b; line-height: 1.5;">
@if($attendeeName)
<p>Hi {{ $attendeeName }},</p>
@endif
<p>You are registered for <strong>{{ $eventName }}</strong>.</p>
<p>Your badge code: <strong>{{ $badgeCode }}</strong></p>
@if(!empty($joinUrl))
<p><a href="{{ $joinUrl }}">Join the virtual session</a></p>
@endif
</body>
</html>
+17 -1
View File
@@ -713,20 +713,36 @@
@php
$evFormat = $content['format'] ?? 'in_person';
$evVirtualSessions = collect($content['virtual_sessions'] ?? [])->filter(fn ($s) => is_array($s) && ! empty($s['join_url']));
$meetAccess = app(\App\Services\Meet\EventMeetAccessService::class);
$evVirtualSessions = collect($content['virtual_sessions'] ?? [])
->filter(fn ($s) => is_array($s) && ! empty($s['join_url']));
@endphp
@if(in_array($evFormat, ['virtual', 'hybrid'], true) && $evVirtualSessions->isNotEmpty())
<div class="mt-6 space-y-3">
<h2 class="text-center text-sm font-bold text-slate-900">Virtual sessions</h2>
@foreach($evVirtualSessions as $session)
@php $windowOpen = $meetAccess->isJoinWindowOpen($session); @endphp
@if($windowOpen)
<a href="{{ $session['join_url'] }}" target="_blank" rel="noopener"
class="block rounded-2xl border border-indigo-100 bg-white px-4 py-3 shadow-sm transition hover:border-indigo-300">
<p class="font-semibold text-slate-900">{{ $session['title'] ?? 'Join session' }}</p>
@if(!empty($session['scheduled_at']))
<p class="mt-1 text-xs text-slate-500">{{ \App\Support\Qr\QrDateFormatter::forDisplay($session['scheduled_at']) }}</p>
@endif
@if(($session['meet_status'] ?? '') === 'live')
<p class="mt-1 text-xs font-semibold text-emerald-600">Live now</p>
@endif
<p class="mt-2 text-xs font-semibold text-indigo-600">Join virtual session </p>
</a>
@else
<div class="block rounded-2xl border border-slate-200 bg-slate-50 px-4 py-3">
<p class="font-semibold text-slate-700">{{ $session['title'] ?? 'Virtual session' }}</p>
@if(!empty($session['scheduled_at']))
<p class="mt-1 text-xs text-slate-500">{{ \App\Support\Qr\QrDateFormatter::forDisplay($session['scheduled_at']) }}</p>
@endif
<p class="mt-2 text-xs text-amber-700">Join opens 15 minutes before the session starts.</p>
</div>
@endif
@endforeach
</div>
@endif
+2
View File
@@ -8,6 +8,8 @@ use Illuminate\Support\Facades\Route;
// Signed SSL completion callback from Ladill Domains (HMAC-verified, no session).
Route::post('/ssl-callback', SslCallbackController::class)->name('api.ssl-callback');
Route::post('/meet/webhooks', \App\Http\Controllers\Api\MeetWebhookController::class)->name('api.meet.webhooks');
Route::middleware(['auth:sanctum', \App\Http\Middleware\SetActingAccount::class])->prefix('v1')->group(function () {
Route::get('/me', MeController::class);
+2
View File
@@ -85,6 +85,8 @@ Route::middleware(['auth', 'platform.session'])->group(function () {
Route::get('/events/{event}/badges.zpl', [AttendeeController::class, 'badgesZpl'])->name('events.badges.zpl');
Route::patch('/events/{event}/attendees/{registration}/check-in', [AttendeeController::class, 'checkIn'])->name('events.attendees.check-in');
Route::post('/events/{event}/attendees/share-programme', [AttendeeController::class, 'shareProgramme'])->name('events.attendees.share-programme');
Route::get('/events/{event}/attendees/comms-preview', [AttendeeController::class, 'previewComms'])->name('events.attendees.comms-preview');
Route::post('/events/{event}/attendees/share-comms', [AttendeeController::class, 'shareComms'])->name('events.attendees.share-comms');
Route::get('/programmes', [ProgrammeController::class, 'index'])->name('programmes.index');
Route::get('/programmes/create', fn () => redirect()->route('events.create', ['type' => 'itinerary']))->name('programmes.create');