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:
@@ -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));
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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,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<string, mixed> $content */
|
||||
|
||||
@@ -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 => [],
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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']);
|
||||
}
|
||||
|
||||
|
||||
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user