diff --git a/.env.example b/.env.example index d2f4f4a..62e8d8c 100644 --- a/.env.example +++ b/.env.example @@ -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= diff --git a/app/Http/Controllers/Api/MeetWebhookController.php b/app/Http/Controllers/Api/MeetWebhookController.php new file mode 100644 index 0000000..43aba97 --- /dev/null +++ b/app/Http/Controllers/Api/MeetWebhookController.php @@ -0,0 +1,39 @@ +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 $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]); + } +} diff --git a/app/Http/Controllers/Events/AttendeeController.php b/app/Http/Controllers/Events/AttendeeController.php index 6ba97a3..b174f71 100644 --- a/app/Http/Controllers/Events/AttendeeController.php +++ b/app/Http/Controllers/Events/AttendeeController.php @@ -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)); + } + + public function shareComms(QrCode $event, Request $request): RedirectResponse + { + $this->authorize('view', $event); + abort_unless($event->type === QrCode::TYPE_EVENT, 404); + + $validated = $request->validate([ + 'mode' => ['required', Rule::in([ + EventCommsService::MODE_PROGRAMME, + EventCommsService::MODE_JOIN, + EventCommsService::MODE_BOTH, + ])], + ]); + + $preview = $this->comms->preview($event, $validated['mode']); + if (! $preview['affordable']) { + return back()->with('error', 'Insufficient wallet balance. Add funds in Billing before sending.'); } - $recipients = $event->eventRegistrations() - ->where('status', QrEventRegistration::STATUS_CONFIRMED) - ->get(); - - $eventName = $event->content()['name'] ?? $event->label; - $programmeUrl = $programme->publicUrl(); - $emailed = 0; - $texted = 0; - - 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++; - } - - 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 diff --git a/app/Services/Events/EventCommsService.php b/app/Services/Events/EventCommsService.php new file mode 100644 index 0000000..07d7f05 --- /dev/null +++ b/app/Services/Events/EventCommsService.php @@ -0,0 +1,168 @@ +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 */ + 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)); + } +} diff --git a/app/Services/Events/EventEmailService.php b/app/Services/Events/EventEmailService.php index e9c7d0d..02937f6 100644 --- a/app/Services/Events/EventEmailService.php +++ b/app/Services/Events/EventEmailService.php @@ -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 $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(['
', '
', '
'], "\n", $html)), ); diff --git a/app/Services/Events/EventProgrammeService.php b/app/Services/Events/EventProgrammeService.php new file mode 100644 index 0000000..140d186 --- /dev/null +++ b/app/Services/Events/EventProgrammeService.php @@ -0,0 +1,74 @@ +>}|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 $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> $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 */ + 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); + } +} diff --git a/app/Services/Events/EventRegistrationService.php b/app/Services/Events/EventRegistrationService.php index f1c2c0b..c468098 100644 --- a/app/Services/Events/EventRegistrationService.php +++ b/app/Services/Events/EventRegistrationService.php @@ -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,37 +262,52 @@ class EventRegistrationService $content = $registration->qrCode?->content() ?? []; $eventName = $content['name'] ?? $registration->qrCode?->label ?? config('app.name'); $firstName = explode(' ', $registration->attendee_name)[0]; - - if (($content['mode'] ?? 'ticketing') === 'contributions') { - $message = sprintf( - 'Hi %s, thank you for your %s of %s %s to %s. Ref: %s', - $firstName, - $registration->tier_name, - $registration->currency, - number_format($registration->amountCedis(), 2), - $eventName, - $registration->badge_code - ); - } else { - $message = sprintf( - 'Hi %s, you are registered for %s (%s). Badge code: %s', - $firstName, - $eventName, - $registration->tier_name, - $registration->badge_code - ); - } - $joinUrl = $this->nextVirtualJoinUrl($content); - if ($joinUrl !== '') { - $message .= ' Join: '.$joinUrl; + + if ($registration->attendee_email) { + $this->email->sendRegistrationConfirmation( + $ownerPublicId, + $registration->attendee_email, + $eventName, + $registration->badge_code, + $joinUrl !== '' ? $joinUrl : null, + $registration->attendee_name, + ); } - $this->sms->send( - $ownerPublicId, - $registration->attendee_phone, - $message, - ); + 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', + $firstName, + $registration->tier_name, + $registration->currency, + number_format($registration->amountCedis(), 2), + $eventName, + $registration->badge_code + ); + } else { + $message = sprintf( + 'Hi %s, you are registered for %s (%s). Badge code: %s', + $firstName, + $eventName, + $registration->tier_name, + $registration->badge_code + ); + } + + if ($joinUrl !== '') { + $message .= ' Join: '.$joinUrl; + } + + $this->sms->send( + $ownerPublicId, + $registration->attendee_phone, + $message, + ); + } + + $this->meetAccess->syncRegistration($registration->fresh()); } /** @param array $content */ diff --git a/app/Services/Meet/EventMeetAccessService.php b/app/Services/Meet/EventMeetAccessService.php new file mode 100644 index 0000000..09a7f21 --- /dev/null +++ b/app/Services/Meet/EventMeetAccessService.php @@ -0,0 +1,75 @@ +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)); + } +} diff --git a/app/Services/Meet/EventMeetSyncService.php b/app/Services/Meet/EventMeetSyncService.php index 4580b79..e7b8004 100644 --- a/app/Services/Meet/EventMeetSyncService.php +++ b/app/Services/Meet/EventMeetSyncService.php @@ -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|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 $hosts */ + private function inviteEmailsFromHosts(array $hosts): array + { + return collect($hosts) + ->filter(fn ($host) => filter_var($host, FILTER_VALIDATE_EMAIL)) + ->values() + ->all(); + } + /** @param list $keepIds */ private function cancelRemovedSessions(QrCode $event, User $owner, array $keepIds): void { diff --git a/app/Services/Meet/MeetLifecycleService.php b/app/Services/Meet/MeetLifecycleService.php new file mode 100644 index 0000000..1b2cf47 --- /dev/null +++ b/app/Services/Meet/MeetLifecycleService.php @@ -0,0 +1,71 @@ + $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 */ + 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 => [], + }; + } +} diff --git a/app/Services/Qr/QrCodeManagerService.php b/app/Services/Qr/QrCodeManagerService.php index 50b96ae..bd96ca6 100644 --- a/app/Services/Qr/QrCodeManagerService.php +++ b/app/Services/Qr/QrCodeManagerService.php @@ -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']); } diff --git a/app/Services/Qr/QrPayloadValidator.php b/app/Services/Qr/QrPayloadValidator.php index f3af4dd..479ae96 100644 --- a/app/Services/Qr/QrPayloadValidator.php +++ b/app/Services/Qr/QrPayloadValidator.php @@ -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), diff --git a/config/meet.php b/config/meet.php index 2fc09c1..3492bef 100644 --- a/config/meet.php +++ b/config/meet.php @@ -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'), ]; diff --git a/resources/views/events/attendees.blade.php b/resources/views/events/attendees.blade.php index 99ed4ca..b4f78bb 100644 --- a/resources/views/events/attendees.blade.php +++ b/resources/views/events/attendees.blade.php @@ -32,42 +32,74 @@ @endunless - {{-- Share programme outline --}} - @if($programme) -
-
- - - -
-

Programme outline

-

Email & text the {{ $programme->label }} programme link to all confirmed attendees.

+ {{-- Mass comms --}} + @if(!empty($commsModes)) +
+
+
+ + + +
+

Message attendees

+

Send programme links, virtual join links, or both. SMS and email are billed from your wallet.

+ +
+
+ @csrf + + +
-
- @csrf - -
@endif {{-- Stats --}} -
+

{{ number_format($stats['total']) }}

{{ $isContribution ? 'Contributions' : 'Registered' }}

@@ -82,6 +114,16 @@

GHS {{ number_format($stats['revenue'], 2) }}

{{ $isContribution ? 'Total raised' : 'Revenue' }}

+ @if(isset($stats['virtual_sessions'])) +
+

{{ number_format($stats['virtual_live']) }}/{{ number_format($stats['virtual_sessions']) }}

+

Sessions live

+
+
+

{{ number_format($stats['virtual_recordings']) }}

+

Recordings ready

+
+ @endif
{{-- Toolbar --}} diff --git a/resources/views/mail/notifications/event-join-programme.blade.php b/resources/views/mail/notifications/event-join-programme.blade.php new file mode 100644 index 0000000..ad7e555 --- /dev/null +++ b/resources/views/mail/notifications/event-join-programme.blade.php @@ -0,0 +1,13 @@ + + + +@if($attendeeName) +

Hi {{ $attendeeName }},

+@endif +

Here is everything you need for {{ $eventName }}:

+ + + diff --git a/resources/views/mail/notifications/event-join.blade.php b/resources/views/mail/notifications/event-join.blade.php new file mode 100644 index 0000000..46d0647 --- /dev/null +++ b/resources/views/mail/notifications/event-join.blade.php @@ -0,0 +1,10 @@ + + + +@if($attendeeName) +

Hi {{ $attendeeName }},

+@endif +

Your virtual session for {{ $eventName }} is ready.

+

Join the session

+ + diff --git a/resources/views/mail/notifications/event-registration-confirmed.blade.php b/resources/views/mail/notifications/event-registration-confirmed.blade.php new file mode 100644 index 0000000..3411223 --- /dev/null +++ b/resources/views/mail/notifications/event-registration-confirmed.blade.php @@ -0,0 +1,13 @@ + + + +@if($attendeeName) +

Hi {{ $attendeeName }},

+@endif +

You are registered for {{ $eventName }}.

+

Your badge code: {{ $badgeCode }}

+@if(!empty($joinUrl)) +

Join the virtual session

+@endif + + diff --git a/resources/views/public/qr/landing.blade.php b/resources/views/public/qr/landing.blade.php index 75df245..d64857d 100644 --- a/resources/views/public/qr/landing.blade.php +++ b/resources/views/public/qr/landing.blade.php @@ -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())

Virtual sessions

@foreach($evVirtualSessions as $session) + @php $windowOpen = $meetAccess->isJoinWindowOpen($session); @endphp + @if($windowOpen)

{{ $session['title'] ?? 'Join session' }}

@if(!empty($session['scheduled_at']))

{{ \App\Support\Qr\QrDateFormatter::forDisplay($session['scheduled_at']) }}

@endif + @if(($session['meet_status'] ?? '') === 'live') +

Live now

+ @endif

Join virtual session →

+ @else +
+

{{ $session['title'] ?? 'Virtual session' }}

+ @if(!empty($session['scheduled_at'])) +

{{ \App\Support\Qr\QrDateFormatter::forDisplay($session['scheduled_at']) }}

+ @endif +

Join opens 15 minutes before the session starts.

+
+ @endif @endforeach
@endif diff --git a/routes/api.php b/routes/api.php index 0edf222..f8eacc3 100644 --- a/routes/api.php +++ b/routes/api.php @@ -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); diff --git a/routes/web.php b/routes/web.php index 2b28b6c..21a7ba1 100644 --- a/routes/web.php +++ b/routes/web.php @@ -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');