From 99ffc510f98869d11dc28b5fd19fc1ec47b35c59 Mon Sep 17 00:00:00 2001 From: isaacclad Date: Fri, 3 Jul 2026 16:01:47 +0000 Subject: [PATCH] Improve speaker invites and redesign roster add flow. Surface specific send failures, allow resend, use ladl.link portal URLs, and replace stacked speaker forms with a single add-to-list pattern. Co-authored-by: Cursor --- .../Controllers/Events/SpeakerController.php | 9 +- app/Services/Billing/PlatformEmailClient.php | 7 +- .../Events/EventSpeakerInviteService.php | 77 ++++++++----- resources/views/events/speakers.blade.php | 109 +++++++++++++----- tests/Feature/EventSpeakerInviteTest.php | 69 +++++++++++ 5 files changed, 209 insertions(+), 62 deletions(-) diff --git a/app/Http/Controllers/Events/SpeakerController.php b/app/Http/Controllers/Events/SpeakerController.php index d4c4be0..2ab0cb7 100644 --- a/app/Http/Controllers/Events/SpeakerController.php +++ b/app/Http/Controllers/Events/SpeakerController.php @@ -90,10 +90,13 @@ class SpeakerController extends Controller 'email' => ['required', 'email', 'max:190'], ]); - $sent = $this->invites->sendManualInvite($event, $event->user, $validated['email']); + $owner = ladill_account() ?? $event->user; + abort_unless($owner, 500); - if (! $sent) { - return back()->with('error', 'Could not send invitation. Check the email address or whether this speaker was already invited from the programme.'); + $result = $this->invites->sendManualInvite($event, $owner, $validated['email']); + + if (! $result['ok']) { + return back()->with('error', $result['error']); } return back()->with('success', 'Speaker invitation sent.'); diff --git a/app/Services/Billing/PlatformEmailClient.php b/app/Services/Billing/PlatformEmailClient.php index 080b7f8..2057b7e 100644 --- a/app/Services/Billing/PlatformEmailClient.php +++ b/app/Services/Billing/PlatformEmailClient.php @@ -19,7 +19,7 @@ class PlatformEmailClient public function send(string $ownerPublicId, string $to, string $subject, string $html, ?string $text = null): bool { - if ($this->token() === '') { + if (! $this->isConfigured()) { return false; } @@ -54,4 +54,9 @@ class PlatformEmailClient return false; } } + + public function isConfigured(): bool + { + return $this->token() !== ''; + } } diff --git a/app/Services/Events/EventSpeakerInviteService.php b/app/Services/Events/EventSpeakerInviteService.php index 8cd327b..10a627a 100644 --- a/app/Services/Events/EventSpeakerInviteService.php +++ b/app/Services/Events/EventSpeakerInviteService.php @@ -30,34 +30,28 @@ class EventSpeakerInviteService public function canSendManualInvite(QrCode $event, array $speaker): bool { - if (($speaker['email'] ?? '') === '') { - return false; - } - - if (! $this->speakers->linkedProgramme($event)) { - return true; - } - - if (empty($speaker['invited_at'])) { - return true; - } - - return ($speaker['invite_source'] ?? '') === self::SOURCE_MANUAL; + return ($speaker['email'] ?? '') !== ''; } - public function sendManualInvite(QrCode $event, User $owner, string $email): bool + /** @return array{ok: true}|array{ok: false, error: string} */ + public function sendManualInvite(QrCode $event, User $owner, string $email): array { $email = strtolower(trim($email)); - $speakers = $this->rosterWithInviteState($event); - $index = collect($speakers)->search(fn ($row) => strcasecmp((string) ($row['email'] ?? ''), $email) === 0); + $event = $event->fresh() ?? $event; + $speaker = $this->findSpeakerByEmail($event, $email); - if ($index === false) { - return false; + if ($speaker === null) { + return [ + 'ok' => false, + 'error' => 'That speaker is not on the saved roster. Click Save speakers first, then send the invitation.', + ]; } - $speaker = $speakers[$index]; if (! $this->canSendManualInvite($event, $speaker)) { - return false; + 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); @@ -103,7 +97,7 @@ class EventSpeakerInviteService continue; } - if ($this->deliverInvite($event, $owner, $speaker, self::SOURCE_PROGRAMME)) { + if ($this->deliverInvite($event, $owner, $speaker, self::SOURCE_PROGRAMME)['ok']) { $sent++; } } @@ -161,10 +155,7 @@ class EventSpeakerInviteService public function portalUrl(QrCode $event, string $token): string { - return route('qr.public.speaker.portal', [ - 'shortCode' => $event->short_code, - 'token' => $token, - ]); + return $event->publicPath('speaker/'.$token); } /** @param array $session */ @@ -222,12 +213,21 @@ class EventSpeakerInviteService return $assignments; } - /** @param array $speaker */ - private function deliverInvite(QrCode $event, User $owner, array $speaker, string $source): bool + /** @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 false; + 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(\App\Services\Billing\PlatformEmailClient::class)->isConfigured()) { + return ['ok' => false, 'error' => 'Outbound email is not configured on this Events instance.']; } $token = (string) ($speaker['invite_token'] ?? ''); @@ -239,7 +239,7 @@ class EventSpeakerInviteService $portalUrl = $this->portalUrl($event, $token); $sent = $this->email->sendSpeakerInvite( - $owner->public_id, + $ownerRef, $email, $eventName, $portalUrl, @@ -247,12 +247,27 @@ class EventSpeakerInviteService ); if (! $sent) { - return false; + return [ + 'ok' => false, + 'error' => 'The invitation email could not be sent. Check your Ladill wallet balance and try again.', + ]; } $this->persistSpeakerInviteMeta($event, $email, $token, $source); - return true; + 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 diff --git a/resources/views/events/speakers.blade.php b/resources/views/events/speakers.blade.php index 04915a0..a0c759f 100644 --- a/resources/views/events/speakers.blade.php +++ b/resources/views/events/speakers.blade.php @@ -33,46 +33,101 @@
@csrf @method('PUT') -
-
-

Event speaker roster

-

Every speaker needs an email. They are available when building the programme schedule.

-
- +
+

Event speaker roster

+

Add speakers one at a time. Every speaker needs an email for invitations and the programme schedule.

-
-