> */ public function rosterWithInviteState(QrCode $event): array { return collect((array) ($event->content()['speakers'] ?? [])) ->map(fn ($speaker) => $this->normalizeSpeakerRow(is_array($speaker) ? $speaker : [])) ->filter(fn ($speaker) => ($speaker['name'] ?? '') !== '') ->values() ->all(); } public function canSendManualInvite(QrCode $event, array $speaker): bool { return ($speaker['email'] ?? '') !== ''; } /** @return array{ok: true}|array{ok: false, error: string} */ public function sendManualInvite(QrCode $event, User $owner, string $email): array { $email = strtolower(trim($email)); $event = $event->fresh() ?? $event; $speaker = $this->findSpeakerByEmail($event, $email); if ($speaker === null) { return [ 'ok' => false, 'error' => 'That speaker is not on the saved roster. Click Save speakers first, then send the invitation.', ]; } if (! $this->canSendManualInvite($event, $speaker)) { return [ 'ok' => false, 'error' => 'Add a valid email address for this speaker before sending an invitation.', ]; } return $this->deliverInvite($event, $owner, $speaker, self::SOURCE_MANUAL); } public function inviteProgrammeHosts(QrCode $programme, User $owner): int { if ($programme->type !== QrCode::TYPE_ITINERARY) { return 0; } $sent = 0; foreach ($this->programmes->eventsLinkedToProgramme($programme) as $event) { $sent += $this->inviteHostsForEvent($event->fresh(), $owner, $programme); } return $sent; } public function inviteHostsForEvent(QrCode $event, User $owner, ?QrCode $programme = null): int { $programme ??= $this->speakers->linkedProgramme($event); if (! $programme) { return 0; } $hostEmails = $this->hostSpeakerEmailsFromProgramme($programme); if ($hostEmails === []) { return 0; } $sent = 0; $speakers = $this->rosterWithInviteState($event); foreach ($speakers as $speaker) { $email = strtolower((string) ($speaker['email'] ?? '')); if ($email === '' || ! in_array($email, $hostEmails, true)) { continue; } if (($speaker['invite_source'] ?? '') === self::SOURCE_PROGRAMME && ! empty($speaker['invited_at'])) { continue; } if ($this->deliverInvite($event, $owner, $speaker, self::SOURCE_PROGRAMME)['ok']) { $sent++; } } return $sent; } /** @return array{event: QrCode, speaker: array}|null */ public function resolvePortal(string $shortCode, string $token): ?array { $token = trim($token); if ($token === '') { return null; } $event = QrCode::query() ->where('short_code', $shortCode) ->where('type', QrCode::TYPE_EVENT) ->where('is_active', true) ->first(); if (! $event) { return null; } foreach ($this->rosterWithInviteState($event) as $speaker) { if (hash_equals((string) ($speaker['invite_token'] ?? ''), $token)) { return ['event' => $event, 'speaker' => $speaker]; } } return null; } /** @return array|null */ public function verifyToken(QrCode $event, string $token): ?array { $token = trim($token); if ($token === '') { return null; } foreach ($this->rosterWithInviteState($event) as $speaker) { if (hash_equals((string) ($speaker['invite_token'] ?? ''), $token)) { return [ 'email' => $speaker['email'], 'name' => $speaker['name'], 'role' => $speaker['role'] ?? '', ]; } } return null; } public function portalUrl(QrCode $event, string $token): string { return $event->publicPath('speaker/'.$token); } /** @param array $session */ public function speakerJoinUrl(QrCode $event, array $session, string $token): ?string { $joinUrl = trim((string) ($session['join_url'] ?? '')); if ($joinUrl === '') { return null; } $separator = str_contains($joinUrl, '?') ? '&' : '?'; return $joinUrl.$separator.'speaker='.urlencode($token); } /** @return list */ public function assignmentsForSpeaker(QrCode $event, array $speaker): array { $programme = $this->speakers->linkedProgramme($event); $snapshot = $this->programmes->snapshot($programme); if (! $snapshot) { return []; } $email = strtolower((string) ($speaker['email'] ?? '')); $name = trim((string) ($speaker['name'] ?? '')); $assignments = []; foreach ((array) ($snapshot['days'] ?? []) as $day) { $dayLabel = trim((string) ($day['date'] ?? $day['label'] ?? '')); foreach ((array) ($day['items'] ?? []) as $item) { if (! is_array($item)) { continue; } $hostEmail = strtolower(trim((string) ($item['host_speaker_email'] ?? ''))); $hostName = trim((string) ($item['host'] ?? '')); $matches = ($email !== '' && $hostEmail === $email) || ($name !== '' && strcasecmp($hostName, $name) === 0); if (! $matches) { continue; } $assignments[] = [ 'session' => trim((string) ($item['title'] ?? '')), 'day' => $dayLabel, 'time' => trim((string) ($item['time'] ?? '')), 'location' => trim((string) ($item['location'] ?? '')), ]; } } return $assignments; } /** @return array{ok: true}|array{ok: false, error: string} */ private function deliverInvite(QrCode $event, User $owner, array $speaker, string $source): array { $email = trim((string) ($speaker['email'] ?? '')); if ($email === '') { return ['ok' => false, 'error' => 'Add a valid email address for this speaker before sending an invitation.']; } $ownerRef = trim((string) ($owner->public_id ?? '')); if ($ownerRef === '') { return ['ok' => false, 'error' => 'Could not resolve the event owner account for sending email.']; } if (! app(EventMailer::class)->isConfigured()) { return ['ok' => false, 'error' => 'Outbound email is not configured on this Events instance.']; } $token = (string) ($speaker['invite_token'] ?? ''); if ($token === '') { $token = Str::random(48); } $eventName = (string) ($event->content()['name'] ?? $event->label); $portalUrl = $this->portalUrl($event, $token); $sent = $this->email->sendSpeakerInvite( $ownerRef, $email, $eventName, $portalUrl, $speaker['name'] ?? null, $owner->email, $owner->name, ); if (! $sent) { return [ 'ok' => false, 'error' => $this->email->lastError() ?: 'The invitation email could not be sent. Check your Bird email balance and verified sending domain, then try again.', ]; } $this->persistSpeakerInviteMeta($event, $email, $token, $source); return ['ok' => true]; } /** @return array|null */ private function findSpeakerByEmail(QrCode $event, string $email): ?array { foreach ($this->rosterWithInviteState($event) as $speaker) { if (strcasecmp((string) ($speaker['email'] ?? ''), $email) === 0) { return $speaker; } } return null; } private function persistSpeakerInviteMeta(QrCode $event, string $email, string $token, string $source): void { $email = strtolower(trim($email)); $content = $event->content(); $speakers = []; foreach ((array) ($content['speakers'] ?? []) as $row) { if (! is_array($row)) { continue; } $rowEmail = strtolower(trim((string) ($row['email'] ?? ''))); if ($rowEmail === $email) { $row['invite_token'] = $token; $row['invited_at'] = now()->toIso8601String(); $row['invite_source'] = $source; } $speakers[] = $row; } $payload = (array) ($event->payload ?? []); $payload['content'] = array_merge($content, ['speakers' => $speakers]); $event->payload = $payload; $event->save(); } /** @return list */ private function hostSpeakerEmailsFromProgramme(QrCode $programme): array { $emails = []; foreach ((array) ($programme->content()['days'] ?? []) as $day) { foreach ((array) ($day['items'] ?? []) as $item) { if (! is_array($item)) { continue; } $email = strtolower(trim((string) ($item['host_speaker_email'] ?? ''))); if ($email !== '' && filter_var($email, FILTER_VALIDATE_EMAIL)) { $emails[] = $email; } } } return array_values(array_unique($emails)); } /** @param array $speaker */ private function normalizeSpeakerRow(array $speaker): array { $name = trim((string) ($speaker['name'] ?? '')); $email = trim((string) ($speaker['email'] ?? '')); return [ 'name' => $name, 'email' => filter_var($email, FILTER_VALIDATE_EMAIL) ? $email : '', 'role' => trim((string) ($speaker['role'] ?? '')), 'bio' => trim((string) ($speaker['bio'] ?? '')), 'invite_token' => trim((string) ($speaker['invite_token'] ?? '')), 'invited_at' => $speaker['invited_at'] ?? null, 'invite_source' => $speaker['invite_source'] ?? null, ]; } }