diff --git a/.env.example b/.env.example index 8aefc8e..1cc8b24 100644 --- a/.env.example +++ b/.env.example @@ -52,6 +52,7 @@ EVENTS_API_KEY_MEET= SMS_PLATFORM_API_URL=https://ladill.com/api SMS_API_KEY_EVENTS= +SMS_CUSTOMER_RELAY_URL=https://ladill.com/api/sms SMS_DEFAULT_SENDER_ID=Ladill # Transactional email (platform mailer — same pattern as Invoice) @@ -66,6 +67,7 @@ MAIL_FROM_NAME=Ladill # Legacy Bird SMTP API (unused for attendee/speaker mail; kept for future Bird integration) SMTP_PLATFORM_API_URL=https://ladill.com/api/smtp SMTP_API_KEY_EVENTS= +SMTP_CUSTOMER_RELAY_URL=https://ladill.com/api/smtp EVENTS_SMTP_FROM=events@ladill.com EVENTS_SMTP_FROM_NAME="Ladill Events" diff --git a/app/Http/Controllers/Events/AttendeeController.php b/app/Http/Controllers/Events/AttendeeController.php index 1b150c1..fe1d8c0 100644 --- a/app/Http/Controllers/Events/AttendeeController.php +++ b/app/Http/Controllers/Events/AttendeeController.php @@ -182,8 +182,8 @@ class AttendeeController extends Controller ]); $preview = $this->comms->preview($event, $validated['mode']); - if (! $preview['affordable']) { - return back()->with('error', 'Insufficient wallet balance. Add funds in Billing before sending.'); + if ($preview['integrations_error'] ?? null) { + return back()->with('error', $preview['integrations_error']); } if ($preview['recipients'] === 0) { diff --git a/app/Http/Controllers/Qr/IntegrationsController.php b/app/Http/Controllers/Qr/IntegrationsController.php new file mode 100644 index 0000000..7adb7d0 --- /dev/null +++ b/app/Http/Controllers/Qr/IntegrationsController.php @@ -0,0 +1,102 @@ + $account, + 'credential' => $credentials->forOwner((string) $account->public_id), + ]); + } + + public function saveSms(Request $request, MessagingCredentialsService $credentials): RedirectResponse + { + $account = ladill_account(); + $ownerRef = (string) $account->public_id; + + $data = $request->validate([ + 'sms_api_key' => ['required', 'string', 'max:200'], + 'sms_sender_id' => ['required', 'string', 'max:11'], + ]); + + $result = $credentials->validateAndSaveSms( + $ownerRef, + $data['sms_api_key'], + $data['sms_sender_id'], + ); + + if (! ($result['ok'] ?? false)) { + return back()->withInput()->with('error', $result['error'] ?? 'Could not save SMS credentials.'); + } + + return back()->with('success', 'Ladill SMS connected. Outbound attendee SMS will use your key and sender ID.'); + } + + public function disconnectSms(MessagingCredentialsService $credentials): RedirectResponse + { + $account = ladill_account(); + $credentials->disconnectSms((string) $account->public_id); + + return back()->with('success', 'Ladill SMS disconnected.'); + } + + public function saveBird(Request $request, MessagingCredentialsService $credentials): RedirectResponse + { + $account = ladill_account(); + $ownerRef = (string) $account->public_id; + + $data = $request->validate([ + 'bird_api_key' => ['required', 'string', 'max:200'], + 'bird_from_email' => ['required', 'email', 'max:255'], + 'bird_from_name' => ['nullable', 'string', 'max:100'], + ]); + + $result = $credentials->validateAndSaveBird( + $ownerRef, + $data['bird_api_key'], + $data['bird_from_email'], + $data['bird_from_name'] ?? null, + ); + + if (! ($result['ok'] ?? false)) { + return back()->withInput()->with('error', $result['error'] ?? 'Could not save Bird credentials.'); + } + + return back()->with('success', 'Ladill Bird connected. Outbound attendee email will use your key and from address.'); + } + + public function disconnectBird(MessagingCredentialsService $credentials): RedirectResponse + { + $account = ladill_account(); + $credentials->disconnectBird((string) $account->public_id); + + return back()->with('success', 'Ladill Bird disconnected.'); + } + + public function previewSenders(Request $request, CustomerSmsClient $sms): JsonResponse + { + $data = $request->validate([ + 'sms_api_key' => ['required', 'string', 'max:200'], + ]); + + $result = $sms->senders(trim($data['sms_api_key'])); + if (! ($result['ok'] ?? false)) { + return response()->json(['error' => $result['error'] ?? 'Could not load senders.'], 422); + } + + return response()->json($result['data'] ?? []); + } +} diff --git a/app/Models/MessagingCredential.php b/app/Models/MessagingCredential.php new file mode 100644 index 0000000..b80fff4 --- /dev/null +++ b/app/Models/MessagingCredential.php @@ -0,0 +1,87 @@ + 'datetime', + 'bird_validated_at' => 'datetime', + ]; + + public function hasValidSms(): bool + { + return $this->sms_status === self::STATUS_VALID + && filled($this->sms_api_key_encrypted) + && filled($this->sms_sender_id); + } + + public function hasValidBird(): bool + { + return $this->bird_status === self::STATUS_VALID + && filled($this->bird_api_key_encrypted) + && filled($this->bird_from_email); + } + + public function smsApiKey(): ?string + { + if (! filled($this->sms_api_key_encrypted)) { + return null; + } + + try { + return Crypt::decryptString($this->sms_api_key_encrypted); + } catch (\Throwable) { + return null; + } + } + + public function birdApiKey(): ?string + { + if (! filled($this->bird_api_key_encrypted)) { + return null; + } + + try { + return Crypt::decryptString($this->bird_api_key_encrypted); + } catch (\Throwable) { + return null; + } + } + + public static function encryptKey(string $plain): string + { + return Crypt::encryptString($plain); + } +} diff --git a/app/Services/Billing/SmsService.php b/app/Services/Billing/SmsService.php index acfa99c..d7852fe 100644 --- a/app/Services/Billing/SmsService.php +++ b/app/Services/Billing/SmsService.php @@ -2,15 +2,32 @@ namespace App\Services\Billing; +use App\Services\Messaging\CustomerSmsClient; +use App\Services\Messaging\MessagingCredentialsService; + class SmsService { - public function __construct(private readonly PlatformSmsClient $platform) {} + private ?string $lastError = null; - public function send(string $ownerPublicId, string $to, string $message): void + public function __construct( + private readonly CustomerSmsClient $customer, + private readonly MessagingCredentialsService $credentials, + ) {} + + public function lastError(): ?string { + return $this->lastError; + } + + public function send(string $ownerPublicId, string $to, string $message): bool + { + $this->lastError = null; + $phone = preg_replace('/\D+/', '', $to); if (strlen($phone) < 9) { - return; + $this->lastError = 'Enter a valid phone number.'; + + return false; } if (strlen($phone) === 9) { @@ -19,6 +36,25 @@ class SmsService $phone = '233'.substr($phone, 1); } - $this->platform->send($ownerPublicId, $phone, $message); + $credential = $this->credentials->forOwner($ownerPublicId); + if (! $credential->hasValidSms()) { + $this->lastError = 'Connect Ladill SMS in Integrations before sending attendee SMS.'; + + return false; + } + + $apiKey = $credential->smsApiKey(); + if (! $apiKey) { + $this->lastError = 'SMS credentials could not be decrypted. Reconnect SMS in Integrations.'; + + return false; + } + + $sent = $this->customer->send($apiKey, $phone, $message, (string) $credential->sms_sender_id); + if (! $sent) { + $this->lastError = $this->customer->lastError() ?: 'The SMS could not be sent.'; + } + + return $sent; } } diff --git a/app/Services/Events/EventCommsService.php b/app/Services/Events/EventCommsService.php index 787aa9e..fbf8104 100644 --- a/app/Services/Events/EventCommsService.php +++ b/app/Services/Events/EventCommsService.php @@ -4,8 +4,8 @@ namespace App\Services\Events; use App\Models\QrCode; use App\Models\QrEventRegistration; -use App\Services\Billing\BillingClient; use App\Services\Billing\SmsService; +use App\Services\Messaging\MessagingCredentialsService; use Illuminate\Support\Collection; class EventCommsService @@ -21,11 +21,11 @@ class EventCommsService public function __construct( private readonly EventEmailService $email, private readonly SmsService $sms, - private readonly BillingClient $billing, + private readonly MessagingCredentialsService $credentials, private readonly EventProgrammeService $programmes, ) {} - /** @return array{recipients: int, emails: int, sms: int, estimated_ghs: float, affordable: bool} */ + /** @return array{recipients: int, emails: int, sms: int, estimated_ghs: float, affordable: bool, integrations_error: ?string} */ public function preview(QrCode $event, string $mode): array { $regs = $this->confirmedRegistrations($event); @@ -34,14 +34,15 @@ class EventCommsService $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)); + $integrationsError = $this->integrationsError($event, $mode, $emails, $phones); return [ 'recipients' => $regs->count(), 'emails' => $emails, 'sms' => $phones, 'estimated_ghs' => $estimated, - 'affordable' => $affordable, + 'affordable' => $integrationsError === null, + 'integrations_error' => $integrationsError, ]; } @@ -51,7 +52,7 @@ class EventCommsService public function send(QrCode $event, string $mode): array { $preview = $this->preview($event, $mode); - abort_unless($preview['affordable'], 402, 'Insufficient wallet balance for this send.'); + abort_if($preview['integrations_error'] !== null, 422, $preview['integrations_error']); $programme = $this->programmeFor($event); $eventName = $event->content()['name'] ?? $event->label; @@ -86,8 +87,7 @@ class EventCommsService if ($reg->attendee_phone) { $message = $this->smsBody($reg, $eventName, $mode, $programmeUrl, $joinUrl); - if ($message !== '') { - $this->sms->send($ownerRef, $reg->attendee_phone, $message); + if ($message !== '' && $this->sms->send($ownerRef, $reg->attendee_phone, $message)) { $texted++; $sent = true; } @@ -167,4 +167,35 @@ class EventCommsService return max(1, (int) ceil(strlen($sample) / self::SMS_SEGMENT_CHARS)); } + + private function integrationsError(QrCode $event, string $mode, int $emails, int $phones): ?string + { + $ownerRef = (string) $event->user->public_id; + $credential = $this->credentials->forOwner($ownerRef); + $programmeUrl = $this->programmeFor($event)?->publicUrl(); + $joinUrl = $this->primaryJoinUrl($event); + + $needsEmail = $emails > 0 && ( + (in_array($mode, [self::MODE_PROGRAMME, self::MODE_BOTH], true) && $programmeUrl) + || ($mode === self::MODE_JOIN && $joinUrl !== '') + ); + + $needsSms = $phones > 0 && $this->smsBody( + new QrEventRegistration(['attendee_name' => 'Guest']), + $event->content()['name'] ?? $event->label, + $mode, + $programmeUrl, + $joinUrl, + ) !== ''; + + if ($needsEmail && ! $credential->hasValidBird()) { + return 'Connect Ladill Bird in Integrations before sending attendee email.'; + } + + if ($needsSms && ! $credential->hasValidSms()) { + return 'Connect Ladill SMS in Integrations before sending attendee SMS.'; + } + + return null; + } } diff --git a/app/Services/Events/EventMailer.php b/app/Services/Events/EventMailer.php index cf51dad..023a7c8 100644 --- a/app/Services/Events/EventMailer.php +++ b/app/Services/Events/EventMailer.php @@ -2,31 +2,36 @@ namespace App\Services\Events; -use App\Services\Billing\BillingClient; -use Illuminate\Support\Facades\Log; -use Illuminate\Support\Facades\Mail; -use Illuminate\Support\Str; +use App\Services\Messaging\CustomerEmailClient; +use App\Services\Messaging\MessagingCredentialsService; /** - * Sends Ladill Events transactional email via the platform mailer - * (noreply@ladill.com — same as CRM and other Ladill apps). + * Sends Ladill Events transactional email to attendees and speakers via the + * customer's Ladill Bird relay when configured in Integrations. */ class EventMailer { - public const EMAIL_PRICE_MINOR = 1; - private ?string $lastError = null; - public function __construct(private readonly BillingClient $billing) {} + public function __construct( + private readonly MessagingCredentialsService $credentials, + private readonly CustomerEmailClient $customerEmail, + ) {} public function lastError(): ?string { return $this->lastError; } + public function isConfiguredForOwner(string $ownerPublicId): bool + { + return $this->credentials->forOwner($ownerPublicId)->hasValidBird(); + } + + /** @deprecated Use isConfiguredForOwner() — attendee email requires per-account Bird credentials. */ public function isConfigured(): bool { - return filter_var((string) config('mail.from.address'), FILTER_VALIDATE_EMAIL) !== false; + return false; } public function send( @@ -40,54 +45,32 @@ class EventMailer ): bool { $this->lastError = null; - if (! $this->isConfigured()) { - $this->lastError = 'Outbound email is not configured on this Events instance.'; + $credential = $this->credentials->forOwner($ownerPublicId); + if (! $credential->hasValidBird()) { + $this->lastError = 'Connect Ladill Bird in Integrations before sending attendee email.'; return false; } - if (! $this->billing->canAfford($ownerPublicId, self::EMAIL_PRICE_MINOR)) { - $this->lastError = 'Insufficient Ladill wallet balance. Add funds in Billing and try again.'; + $apiKey = $credential->birdApiKey(); + if (! $apiKey) { + $this->lastError = 'Bird credentials could not be decrypted. Reconnect Bird in Integrations.'; return false; } - $reference = 'events_email:'.Str::uuid(); + $sent = $this->customerEmail->send( + $apiKey, + (string) $credential->bird_from_email, + $credential->bird_from_name, + $to, + $subject, + $html, + $text, + ); - try { - Mail::html($html, function ($message) use ($to, $subject, $text, $replyToEmail, $replyToName): void { - $message->to($to) - ->subject($subject) - ->from( - (string) config('mail.from.address'), - (string) config('mail.from.name'), - ); - - if ($replyToEmail) { - $message->replyTo($replyToEmail, $replyToName ?: null); - } - - if ($text) { - $message->text($text); - } - }); - } catch (\Throwable $e) { - Log::warning('Events mail send failed', ['error' => $e->getMessage(), 'to' => $to]); - $this->lastError = 'The email could not be sent. Please try again.'; - - return false; - } - - if (! $this->billing->debit( - $ownerPublicId, - self::EMAIL_PRICE_MINOR, - 'smtp', - 'events_email', - $reference, - null, - 'Events email to '.$to, - )) { - $this->lastError = 'Insufficient Ladill wallet balance. Add funds in Billing and try again.'; + if (! $sent) { + $this->lastError = $this->customerEmail->lastError() ?: 'The email could not be sent.'; return false; } diff --git a/app/Services/Events/EventSpeakerInviteService.php b/app/Services/Events/EventSpeakerInviteService.php index f0d3339..717b8c3 100644 --- a/app/Services/Events/EventSpeakerInviteService.php +++ b/app/Services/Events/EventSpeakerInviteService.php @@ -226,8 +226,8 @@ class EventSpeakerInviteService 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.']; + if (! app(EventMailer::class)->isConfiguredForOwner($ownerRef)) { + return ['ok' => false, 'error' => 'Connect Ladill Bird in Integrations before sending speaker invitations.']; } $token = (string) ($speaker['invite_token'] ?? ''); diff --git a/app/Services/Messaging/CustomerEmailClient.php b/app/Services/Messaging/CustomerEmailClient.php new file mode 100644 index 0000000..12c58e0 --- /dev/null +++ b/app/Services/Messaging/CustomerEmailClient.php @@ -0,0 +1,92 @@ +lastError; + } + + private function base(): string + { + return rtrim((string) config('smtp.customer_relay_url', 'https://ladill.com/api/smtp'), '/'); + } + + /** @return array{ok: bool, data?: array, error?: string, status?: int} */ + public function me(string $apiKey): array + { + try { + $res = Http::withToken($apiKey)->acceptJson()->timeout(15) + ->get($this->base().'/me'); + + if ($res->status() === 401) { + return ['ok' => false, 'error' => 'Invalid Bird API key.', 'status' => 401]; + } + + if ($res->failed()) { + return [ + 'ok' => false, + 'error' => (string) ($res->json('error') ?: 'Could not validate Bird credentials.'), + 'status' => $res->status(), + ]; + } + + return ['ok' => true, 'data' => $res->json() ?? [], 'status' => $res->status()]; + } catch (\Throwable $e) { + Log::warning('Customer Bird meta request failed', ['error' => $e->getMessage()]); + + return ['ok' => false, 'error' => 'Could not reach Ladill Bird. Please try again.']; + } + } + + public function send( + string $apiKey, + string $from, + ?string $fromName, + string $to, + string $subject, + string $html, + ?string $text = null, + ): bool { + $this->lastError = null; + + try { + $res = Http::withToken($apiKey)->acceptJson()->timeout(30) + ->post($this->base().'/send', array_filter([ + 'from' => $from, + 'from_name' => $fromName, + 'to' => [$to], + 'subject' => $subject, + 'html' => $html, + 'text' => $text, + ], fn ($value) => $value !== null && $value !== '')); + + if ($res->status() === 402) { + $this->lastError = (string) ($res->json('error') ?: 'Insufficient Bird email credits. Top up Bird and try again.'); + + return false; + } + + if ($res->failed()) { + $this->lastError = (string) ($res->json('error') ?: $res->json('message') ?: 'The email could not be sent.'); + Log::warning('Customer email send failed', ['status' => $res->status()]); + + return false; + } + + return (bool) ($res->json('success') ?? true); + } catch (\Throwable $e) { + $this->lastError = 'Could not reach the email service. Please try again.'; + Log::warning('Customer email send error', ['error' => $e->getMessage()]); + + return false; + } + } +} diff --git a/app/Services/Messaging/CustomerSmsClient.php b/app/Services/Messaging/CustomerSmsClient.php new file mode 100644 index 0000000..37d5274 --- /dev/null +++ b/app/Services/Messaging/CustomerSmsClient.php @@ -0,0 +1,94 @@ +lastError; + } + + private function base(): string + { + return rtrim((string) config('sms.customer_relay_url', 'https://ladill.com/api/sms'), '/'); + } + + /** @return array{ok: bool, data?: array, error?: string, status?: int} */ + public function me(string $apiKey): array + { + return $this->get($apiKey, '/me'); + } + + /** @return array{ok: bool, data?: array, error?: string, status?: int} */ + public function senders(string $apiKey): array + { + return $this->get($apiKey, '/senders'); + } + + public function send(string $apiKey, string $to, string $text, string $senderId): bool + { + $this->lastError = null; + + try { + $res = Http::withToken($apiKey)->acceptJson()->timeout(30) + ->post($this->base().'/send', [ + 'to' => $to, + 'text' => $text, + 'sender_id' => $senderId, + ]); + + if ($res->status() === 402) { + $this->lastError = (string) ($res->json('error') ?: 'Insufficient SMS credit. Top up your Ladill SMS wallet and try again.'); + + return false; + } + + if ($res->failed()) { + $this->lastError = (string) ($res->json('error') ?: $res->json('message') ?: 'The SMS could not be sent.'); + Log::warning('Customer SMS send failed', ['status' => $res->status()]); + + return false; + } + + return (bool) ($res->json('success') ?? true); + } catch (\Throwable $e) { + $this->lastError = 'Could not reach the SMS service. Please try again.'; + Log::warning('Customer SMS send error', ['error' => $e->getMessage()]); + + return false; + } + } + + /** @return array{ok: bool, data?: array, error?: string, status?: int} */ + private function get(string $apiKey, string $path): array + { + try { + $res = Http::withToken($apiKey)->acceptJson()->timeout(15) + ->get($this->base().$path); + + if ($res->status() === 401) { + return ['ok' => false, 'error' => 'Invalid SMS API key.', 'status' => 401]; + } + + if ($res->failed()) { + return [ + 'ok' => false, + 'error' => (string) ($res->json('error') ?: 'Could not validate SMS credentials.'), + 'status' => $res->status(), + ]; + } + + return ['ok' => true, 'data' => $res->json() ?? [], 'status' => $res->status()]; + } catch (\Throwable $e) { + Log::warning('Customer SMS meta request failed', ['path' => $path, 'error' => $e->getMessage()]); + + return ['ok' => false, 'error' => 'Could not reach Ladill SMS. Please try again.']; + } + } +} diff --git a/app/Services/Messaging/MessagingCredentialsService.php b/app/Services/Messaging/MessagingCredentialsService.php new file mode 100644 index 0000000..fa3fb37 --- /dev/null +++ b/app/Services/Messaging/MessagingCredentialsService.php @@ -0,0 +1,163 @@ +firstOrCreate( + ['owner_ref' => $ownerRef], + ); + } + + /** + * @return array{ok: bool, error?: string, credential?: MessagingCredential, senders?: list} + */ + public function validateAndSaveSms(string $ownerRef, string $apiKey, string $senderId): array + { + $apiKey = trim($apiKey); + $senderId = trim($senderId); + + if (! str_starts_with($apiKey, 'lsk_sms_live_')) { + return ['ok' => false, 'error' => 'SMS API keys must start with lsk_sms_live_.']; + } + + if ($senderId === '' || strlen($senderId) > 11) { + return ['ok' => false, 'error' => 'Sender ID must be 1–11 characters.']; + } + + $me = $this->sms->me($apiKey); + if (! ($me['ok'] ?? false)) { + $credential = $this->forOwner($ownerRef); + $credential->update([ + 'sms_status' => MessagingCredential::STATUS_INVALID, + 'sms_last_error' => $me['error'] ?? 'Invalid SMS API key.', + ]); + + return ['ok' => false, 'error' => $me['error'] ?? 'Invalid SMS API key.', 'credential' => $credential]; + } + + $senders = $this->sms->senders($apiKey); + $approved = collect($senders['data']['data'] ?? [])->pluck('sender_id')->map(fn ($id) => (string) $id)->all(); + $defaultSender = (string) ($senders['data']['default_sender'] ?? ''); + + $allowed = array_values(array_unique(array_filter([...$approved, $defaultSender]))); + if ($allowed !== [] && ! in_array($senderId, $allowed, true)) { + $error = 'Sender ID is not approved for this SMS key. Approved: '.implode(', ', $allowed); + + $credential = $this->forOwner($ownerRef); + $credential->update([ + 'sms_status' => MessagingCredential::STATUS_INVALID, + 'sms_last_error' => $error, + ]); + + return ['ok' => false, 'error' => $error, 'credential' => $credential, 'senders' => $allowed]; + } + + $credential = $this->forOwner($ownerRef); + $credential->update([ + 'sms_api_key_encrypted' => MessagingCredential::encryptKey($apiKey), + 'sms_api_key_prefix' => substr($apiKey, 0, 16), + 'sms_sender_id' => $senderId, + 'sms_status' => MessagingCredential::STATUS_VALID, + 'sms_validated_at' => now(), + 'sms_last_error' => null, + ]); + + return ['ok' => true, 'credential' => $credential->fresh(), 'senders' => $allowed]; + } + + /** + * @return array{ok: bool, error?: string, credential?: MessagingCredential} + */ + public function validateAndSaveBird(string $ownerRef, string $apiKey, string $fromEmail, ?string $fromName): array + { + $apiKey = trim($apiKey); + $fromEmail = strtolower(trim($fromEmail)); + $fromName = trim((string) $fromName); + + if (! str_starts_with($apiKey, 'lsk_live_') && ! str_starts_with($apiKey, 'lsk_acct_')) { + return ['ok' => false, 'error' => 'Bird API keys must start with lsk_live_ or lsk_acct_.']; + } + + if (! filter_var($fromEmail, FILTER_VALIDATE_EMAIL)) { + return ['ok' => false, 'error' => 'Enter a valid from email address.']; + } + + $me = $this->email->me($apiKey); + if (! ($me['ok'] ?? false)) { + $credential = $this->forOwner($ownerRef); + $credential->update([ + 'bird_status' => MessagingCredential::STATUS_INVALID, + 'bird_last_error' => $me['error'] ?? 'Invalid Bird API key.', + ]); + + return ['ok' => false, 'error' => $me['error'] ?? 'Invalid Bird API key.', 'credential' => $credential]; + } + + $domains = collect($me['data']['verified_domains'] ?? [])->map(fn ($d) => strtolower((string) $d))->all(); + $fromDomain = strtolower((string) (explode('@', $fromEmail, 2)[1] ?? '')); + if ($domains !== [] && ! in_array($fromDomain, $domains, true)) { + $error = 'From email must use a verified Bird domain ('.implode(', ', $domains).').'; + $credential = $this->forOwner($ownerRef); + $credential->update([ + 'bird_status' => MessagingCredential::STATUS_INVALID, + 'bird_last_error' => $error, + ]); + + return ['ok' => false, 'error' => $error, 'credential' => $credential]; + } + + $credential = $this->forOwner($ownerRef); + $credential->update([ + 'bird_api_key_encrypted' => MessagingCredential::encryptKey($apiKey), + 'bird_api_key_prefix' => substr($apiKey, 0, 16), + 'bird_from_email' => $fromEmail, + 'bird_from_name' => $fromName !== '' ? $fromName : null, + 'bird_status' => MessagingCredential::STATUS_VALID, + 'bird_validated_at' => now(), + 'bird_last_error' => null, + ]); + + return ['ok' => true, 'credential' => $credential->fresh()]; + } + + public function disconnectSms(string $ownerRef): MessagingCredential + { + $credential = $this->forOwner($ownerRef); + $credential->update([ + 'sms_api_key_encrypted' => null, + 'sms_api_key_prefix' => null, + 'sms_sender_id' => null, + 'sms_status' => null, + 'sms_validated_at' => null, + 'sms_last_error' => null, + ]); + + return $credential->fresh(); + } + + public function disconnectBird(string $ownerRef): MessagingCredential + { + $credential = $this->forOwner($ownerRef); + $credential->update([ + 'bird_api_key_encrypted' => null, + 'bird_api_key_prefix' => null, + 'bird_from_email' => null, + 'bird_from_name' => null, + 'bird_status' => null, + 'bird_validated_at' => null, + 'bird_last_error' => null, + ]); + + return $credential->fresh(); + } +} diff --git a/config/sms.php b/config/sms.php index 9e72577..561b51c 100644 --- a/config/sms.php +++ b/config/sms.php @@ -3,5 +3,6 @@ return [ 'platform_api_url' => env('SMS_PLATFORM_API_URL', 'https://ladill.com/api'), 'platform_api_key' => env('SMS_API_KEY_EVENTS'), + 'customer_relay_url' => env('SMS_CUSTOMER_RELAY_URL', 'https://ladill.com/api/sms'), 'default_sender_id' => env('SMS_DEFAULT_SENDER_ID', 'Ladill'), ]; diff --git a/config/smtp.php b/config/smtp.php index 18fbd3f..39333d5 100644 --- a/config/smtp.php +++ b/config/smtp.php @@ -3,6 +3,7 @@ return [ 'platform_api_url' => env('SMTP_PLATFORM_API_URL', 'https://ladill.com/api/smtp'), 'platform_api_key' => env('SMTP_API_KEY_EVENTS'), + 'customer_relay_url' => env('SMTP_CUSTOMER_RELAY_URL', 'https://ladill.com/api/smtp'), 'from' => env('EVENTS_SMTP_FROM', 'events@ladill.com'), 'from_name' => env('EVENTS_SMTP_FROM_NAME', 'Ladill Events'), ]; diff --git a/database/migrations/2026_07_12_100000_create_events_messaging_credentials_table.php b/database/migrations/2026_07_12_100000_create_events_messaging_credentials_table.php new file mode 100644 index 0000000..30de160 --- /dev/null +++ b/database/migrations/2026_07_12_100000_create_events_messaging_credentials_table.php @@ -0,0 +1,35 @@ +id(); + $table->string('owner_ref')->unique(); + $table->text('sms_api_key_encrypted')->nullable(); + $table->string('sms_api_key_prefix', 32)->nullable(); + $table->string('sms_sender_id', 11)->nullable(); + $table->string('sms_status', 32)->nullable(); + $table->timestamp('sms_validated_at')->nullable(); + $table->text('sms_last_error')->nullable(); + $table->text('bird_api_key_encrypted')->nullable(); + $table->string('bird_api_key_prefix', 32)->nullable(); + $table->string('bird_from_email')->nullable(); + $table->string('bird_from_name')->nullable(); + $table->string('bird_status', 32)->nullable(); + $table->timestamp('bird_validated_at')->nullable(); + $table->text('bird_last_error')->nullable(); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('events_messaging_credentials'); + } +}; diff --git a/resources/views/events/attendees.blade.php b/resources/views/events/attendees.blade.php index 46d1fee..09448a7 100644 --- a/resources/views/events/attendees.blade.php +++ b/resources/views/events/attendees.blade.php @@ -54,12 +54,12 @@

Message attendees

-

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

+

Send programme links, virtual join links, or both. Uses your connected Ladill SMS and Bird keys. Manage integrations

diff --git a/resources/views/layouts/user.blade.php b/resources/views/layouts/user.blade.php index d0fa125..32685cb 100644 --- a/resources/views/layouts/user.blade.php +++ b/resources/views/layouts/user.blade.php @@ -1,5 +1,6 @@ @php $mobileFullScreenPage = request()->routeIs('account.settings') + || request()->routeIs('account.integrations') || request()->routeIs('events.create') || request()->routeIs('programmes.create') || request()->routeIs('events.show') diff --git a/resources/views/qr/account/integrations.blade.php b/resources/views/qr/account/integrations.blade.php new file mode 100644 index 0000000..0102638 --- /dev/null +++ b/resources/views/qr/account/integrations.blade.php @@ -0,0 +1,122 @@ + + Integrations + +
+

Messaging integrations

+

+ Connect your own + Ladill SMS + and + Ladill Bird + API keys so attendee messages bill your wallets and send under your brand. +

+ + @if (session('success')) +
{{ session('success') }}
+ @endif + @if (session('error')) +
{{ session('error') }}
+ @endif + +
+
+

Ladill SMS

+

Paste an SMS API key (lsk_sms_live_…) and an approved sender ID from sms.ladill.com.

+ + @if ($credential->hasValidSms()) +
+ Connected · key {{ $credential->sms_api_key_prefix }}… · sender {{ $credential->sms_sender_id }} + @if ($credential->sms_validated_at) + · validated {{ $credential->sms_validated_at->diffForHumans() }} + @endif +
+ @elseif ($credential->sms_status === \App\Models\MessagingCredential::STATUS_INVALID) +
+ {{ $credential->sms_last_error ?: 'SMS credentials are invalid.' }} +
+ @endif + +
+ @csrf +
+ + +
+
+ + +

Must match an approved sender ID on your SMS service (or the platform default).

+
+ +
+ + @if ($credential->sms_api_key_encrypted || $credential->hasValidSms()) +
+ @csrf + +
+ @endif +
+ +
+

Ladill Bird

+

Paste a Bird API key (lsk_live_…) plus a from address on a verified domain from bird.ladill.com.

+ + @if ($credential->hasValidBird()) +
+ Connected · key {{ $credential->bird_api_key_prefix }}… · from + {{ $credential->bird_from_name ? $credential->bird_from_name.' <'.$credential->bird_from_email.'>' : $credential->bird_from_email }} + @if ($credential->bird_validated_at) + · validated {{ $credential->bird_validated_at->diffForHumans() }} + @endif +
+ @elseif ($credential->bird_status === \App\Models\MessagingCredential::STATUS_INVALID) +
+ {{ $credential->bird_last_error ?: 'Bird credentials are invalid.' }} +
+ @endif + +
+ @csrf +
+ + +
+
+ + +
+
+ + +
+ +
+ + @if ($credential->bird_api_key_encrypted || $credential->hasValidBird()) +
+ @csrf + +
+ @endif +
+
+ +

+ ← Back to settings +

+
+
diff --git a/resources/views/qr/account/settings.blade.php b/resources/views/qr/account/settings.blade.php index ace0e9f..edba9ad 100644 --- a/resources/views/qr/account/settings.blade.php +++ b/resources/views/qr/account/settings.blade.php @@ -252,6 +252,12 @@ +
+

Messaging integrations

+

Connect your Ladill SMS and Bird API keys for attendee and speaker messaging.

+ Open messaging integrations → +
+

Profile, password, and security are managed on account.ladill.com. diff --git a/routes/web.php b/routes/web.php index 6486d05..259b56d 100644 --- a/routes/web.php +++ b/routes/web.php @@ -11,6 +11,7 @@ use App\Http\Controllers\NotificationController; use App\Http\Controllers\Public\EventRegistrationController; use App\Http\Controllers\Public\QrScanController; use App\Http\Controllers\Qr\AccountController; +use App\Http\Controllers\Qr\IntegrationsController; use App\Http\Controllers\Qr\AfiaController; use App\Http\Controllers\Qr\DeveloperController; use App\Http\Controllers\Events\CustomDomainController; @@ -107,6 +108,12 @@ Route::middleware(['auth', 'platform.session'])->group(function () { Route::get('/billing', fn () => redirect()->away(ladill_account_url('/billing')))->name('account.billing'); Route::get('/settings', [AccountController::class, 'settings'])->name('account.settings'); Route::put('/settings', [AccountController::class, 'updateSettings'])->name('account.settings.update'); + Route::get('/integrations', [IntegrationsController::class, 'edit'])->name('account.integrations'); + Route::post('/integrations/sms', [IntegrationsController::class, 'saveSms'])->name('account.integrations.sms.save'); + Route::post('/integrations/sms/disconnect', [IntegrationsController::class, 'disconnectSms'])->name('account.integrations.sms.disconnect'); + Route::post('/integrations/bird', [IntegrationsController::class, 'saveBird'])->name('account.integrations.bird.save'); + Route::post('/integrations/bird/disconnect', [IntegrationsController::class, 'disconnectBird'])->name('account.integrations.bird.disconnect'); + Route::post('/integrations/sms/senders', [IntegrationsController::class, 'previewSenders'])->name('account.integrations.sms.senders'); Route::get('/team', fn () => redirect()->away(ladill_account_url('/account/team')))->name('account.team'); Route::post('/switch-account', [TeamController::class, 'switchAccount'])->name('account.switch'); diff --git a/tests/Feature/EventSpeakerInviteTest.php b/tests/Feature/EventSpeakerInviteTest.php index 4ae8b9a..a55b1b3 100644 --- a/tests/Feature/EventSpeakerInviteTest.php +++ b/tests/Feature/EventSpeakerInviteTest.php @@ -2,12 +2,13 @@ namespace Tests\Feature; +use App\Models\MessagingCredential; use App\Models\QrCode; use App\Models\User; use App\Services\Events\EventSpeakerInviteService; use Illuminate\Foundation\Testing\RefreshDatabase; +use Illuminate\Support\Facades\Crypt; use Illuminate\Support\Facades\Http; -use Illuminate\Support\Facades\Mail; use Tests\TestCase; class EventSpeakerInviteTest extends TestCase @@ -20,17 +21,25 @@ class EventSpeakerInviteTest extends TestCase $this->withoutMiddleware(\App\Http\Middleware\EnsurePlatformSession::class); config([ - 'mail.from.address' => 'noreply@ladill.com', - 'mail.from.name' => 'Ladill', 'events.service_api_keys.meet' => 'test-meet-key', ]); - Mail::fake(); - Http::fake([ - config('billing.api_url').'/balance*' => Http::response(['balance_minor' => 50000]), - config('billing.api_url').'/can-afford*' => Http::response(['affordable' => true]), - config('billing.api_url').'/debit' => Http::response(['success' => true]), + '*/api/smtp/send' => Http::response(['success' => true, 'message_id' => 'x']), + ]); + } + + private function seedBirdCredentials(User $owner): void + { + $plain = 'lsk_live_'.str_repeat('f', 32); + MessagingCredential::create([ + 'owner_ref' => $owner->public_id, + 'bird_api_key_encrypted' => Crypt::encryptString($plain), + 'bird_api_key_prefix' => substr($plain, 0, 16), + 'bird_from_email' => 'events@example.test', + 'bird_from_name' => 'Events', + 'bird_status' => MessagingCredential::STATUS_VALID, + 'bird_validated_at' => now(), ]); } @@ -76,6 +85,7 @@ class EventSpeakerInviteTest extends TestCase public function test_manual_speaker_invite_sends_email_and_records_metadata(): void { $owner = User::factory()->create(['public_id' => 'usr_sp_manual']); + $this->seedBirdCredentials($owner); $event = QrCode::create([ 'user_id' => $owner->id, @@ -103,7 +113,7 @@ class EventSpeakerInviteTest extends TestCase ->assertRedirect(route('speakers.show', $event)) ->assertSessionHas('success'); - Http::assertSent(fn ($request) => str_contains($request->url(), '/debit')); + Http::assertSent(fn ($request) => str_contains($request->url(), '/api/smtp/send')); $event->refresh(); $speaker = $event->content()['speakers'][0]; @@ -115,6 +125,7 @@ class EventSpeakerInviteTest extends TestCase public function test_programme_save_invites_assigned_speaker_hosts(): void { $owner = User::factory()->create(['public_id' => 'usr_sp_prog']); + $this->seedBirdCredentials($owner); $programme = QrCode::create([ 'user_id' => $owner->id, @@ -166,7 +177,7 @@ class EventSpeakerInviteTest extends TestCase app(EventSpeakerInviteService::class)->inviteProgrammeHosts($programme, $owner); - Http::assertSent(fn ($request) => str_contains($request->url(), '/debit')); + Http::assertSent(fn ($request) => str_contains($request->url(), '/api/smtp/send')); $event->refresh(); $speaker = $event->content()['speakers'][0]; @@ -232,8 +243,6 @@ class EventSpeakerInviteTest extends TestCase public function test_manual_speaker_invite_reports_when_email_not_configured(): void { - config(['mail.from.address' => '']); - $owner = User::factory()->create(['public_id' => 'usr_sp_no_smtp']); $event = QrCode::create([ @@ -266,6 +275,7 @@ class EventSpeakerInviteTest extends TestCase public function test_manual_speaker_invite_can_be_resent_after_programme_invite(): void { $owner = User::factory()->create(['public_id' => 'usr_sp_resend']); + $this->seedBirdCredentials($owner); $event = QrCode::create([ 'user_id' => $owner->id, @@ -296,6 +306,6 @@ class EventSpeakerInviteTest extends TestCase ->assertRedirect(route('speakers.show', $event)) ->assertSessionHas('success'); - Http::assertSent(fn ($request) => str_contains($request->url(), '/debit')); + Http::assertSent(fn ($request) => str_contains($request->url(), '/api/smtp/send')); } } diff --git a/tests/Feature/EventsMessagingIntegrationsTest.php b/tests/Feature/EventsMessagingIntegrationsTest.php new file mode 100644 index 0000000..a26822b --- /dev/null +++ b/tests/Feature/EventsMessagingIntegrationsTest.php @@ -0,0 +1,242 @@ +withoutMiddleware(EnsurePlatformSession::class); + + $this->user = User::factory()->create([ + 'public_id' => 'test-user-msg-001', + 'name' => 'Test User', + 'email' => 'msg@example.com', + ]); + } + + public function test_integrations_page_loads(): void + { + $this->actingAs($this->user) + ->get(route('account.integrations')) + ->assertOk() + ->assertSee('Ladill SMS') + ->assertSee('Ladill Bird'); + } + + public function test_saving_sms_credentials_encrypts_key(): void + { + Http::fake([ + '*/api/sms/me' => Http::response([ + 'api_key_prefix' => 'lsk_sms_live_abcd', + 'service_id' => 1, + 'status' => 'active', + 'brand_name' => 'Events', + ]), + '*/api/sms/senders' => Http::response([ + 'data' => [['id' => 1, 'sender_id' => 'EventsA', 'is_default' => true]], + 'default_sender' => 'Ladill', + ]), + ]); + + $plain = 'lsk_sms_live_'.str_repeat('a', 32); + + $this->actingAs($this->user) + ->post(route('account.integrations.sms.save'), [ + 'sms_api_key' => $plain, + 'sms_sender_id' => 'EventsA', + ]) + ->assertRedirect() + ->assertSessionHas('success'); + + $row = MessagingCredential::query()->where('owner_ref', $this->user->public_id)->first(); + $this->assertNotNull($row); + $this->assertNotSame($plain, $row->sms_api_key_encrypted); + $this->assertSame($plain, Crypt::decryptString($row->sms_api_key_encrypted)); + $this->assertSame(MessagingCredential::STATUS_VALID, $row->sms_status); + $this->assertSame('EventsA', $row->sms_sender_id); + } + + public function test_invalid_sms_key_is_rejected(): void + { + Http::fake([ + '*/api/sms/me' => Http::response(['error' => 'Invalid or missing API key.'], 401), + ]); + + $this->actingAs($this->user) + ->post(route('account.integrations.sms.save'), [ + 'sms_api_key' => 'lsk_sms_live_'.str_repeat('b', 32), + 'sms_sender_id' => 'EventsA', + ]) + ->assertRedirect() + ->assertSessionHas('error'); + } + + public function test_attendee_comms_requires_credentials(): void + { + $event = $this->createEventWithAttendee(); + + $this->actingAs($this->user) + ->post(route('events.attendees.share-comms', $event), [ + 'mode' => EventCommsService::MODE_JOIN, + ]) + ->assertRedirect() + ->assertSessionHas('error'); + } + + public function test_attendee_sms_uses_customer_relay_key(): void + { + $plain = 'lsk_sms_live_'.str_repeat('c', 32); + MessagingCredential::create([ + 'owner_ref' => $this->user->public_id, + 'sms_api_key_encrypted' => Crypt::encryptString($plain), + 'sms_api_key_prefix' => substr($plain, 0, 16), + 'sms_sender_id' => 'EventsA', + 'sms_status' => MessagingCredential::STATUS_VALID, + 'sms_validated_at' => now(), + ]); + + Http::fake([ + '*/api/sms/send' => Http::response(['success' => true, 'message_id' => 1]), + ]); + + $event = $this->createEventWithAttendee(phoneOnly: true); + + $this->actingAs($this->user) + ->post(route('events.attendees.share-comms', $event), [ + 'mode' => EventCommsService::MODE_JOIN, + ]) + ->assertRedirect() + ->assertSessionHas('success'); + + Http::assertSent(function ($request) use ($plain) { + return str_contains($request->url(), '/api/sms/send') + && $request->hasHeader('Authorization', 'Bearer '.$plain) + && $request['sender_id'] === 'EventsA'; + }); + } + + public function test_attendee_email_uses_customer_bird_key(): void + { + $plain = 'lsk_live_'.str_repeat('d', 32); + MessagingCredential::create([ + 'owner_ref' => $this->user->public_id, + 'bird_api_key_encrypted' => Crypt::encryptString($plain), + 'bird_api_key_prefix' => substr($plain, 0, 16), + 'bird_from_email' => 'events@example.test', + 'bird_from_name' => 'Events', + 'bird_status' => MessagingCredential::STATUS_VALID, + 'bird_validated_at' => now(), + ]); + + Http::fake([ + '*/api/smtp/send' => Http::response(['success' => true, 'message_id' => 'x']), + ]); + + $event = $this->createEventWithAttendee(emailOnly: true); + + $this->actingAs($this->user) + ->post(route('events.attendees.share-comms', $event), [ + 'mode' => EventCommsService::MODE_JOIN, + ]) + ->assertRedirect() + ->assertSessionHas('success'); + + Http::assertSent(function ($request) use ($plain) { + return str_contains($request->url(), '/api/smtp/send') + && $request->hasHeader('Authorization', 'Bearer '.$plain) + && $request['from'] === 'events@example.test' + && $request['from_name'] === 'Events'; + }); + } + + public function test_registration_confirmation_email_uses_customer_bird_key(): void + { + $plain = 'lsk_live_'.str_repeat('e', 32); + MessagingCredential::create([ + 'owner_ref' => $this->user->public_id, + 'bird_api_key_encrypted' => Crypt::encryptString($plain), + 'bird_api_key_prefix' => substr($plain, 0, 16), + 'bird_from_email' => 'events@example.test', + 'bird_from_name' => 'Events', + 'bird_status' => MessagingCredential::STATUS_VALID, + 'bird_validated_at' => now(), + ]); + + Http::fake([ + '*/api/smtp/send' => Http::response(['success' => true, 'message_id' => 'x']), + ]); + + $sent = app(EventEmailService::class)->sendRegistrationConfirmation( + $this->user->public_id, + 'ada@example.com', + 'Demo event', + 'BADGE123', + 'https://meet.test/join', + 'Ada Lovelace', + $this->user->email, + $this->user->name, + ); + + $this->assertTrue($sent); + + Http::assertSent(function ($request) use ($plain) { + return str_contains($request->url(), '/api/smtp/send') + && $request->hasHeader('Authorization', 'Bearer '.$plain); + }); + } + + private function createEventWithAttendee(bool $emailOnly = false, bool $phoneOnly = false): QrCode + { + $event = QrCode::create([ + 'user_id' => $this->user->id, + 'short_code' => 'evt-msg-'.uniqid(), + 'type' => QrCode::TYPE_EVENT, + 'label' => 'Messaging test event', + 'payload' => [ + 'content' => [ + 'name' => 'Messaging test event', + 'format' => 'virtual', + 'virtual_sessions' => [ + ['join_url' => 'https://meet.test/join-room'], + ], + ], + 'style' => [], + ], + 'is_active' => true, + ]); + + QrEventRegistration::create([ + 'qr_code_id' => $event->id, + 'user_id' => $this->user->id, + 'reference' => 'QRE-'.strtoupper(uniqid()), + 'badge_code' => 'BADGE'.random_int(1000, 9999), + 'tier_name' => 'General', + 'amount_minor' => 0, + 'currency' => 'GHS', + 'attendee_name' => 'Ada Lovelace', + 'attendee_email' => $phoneOnly ? null : 'ada@example.com', + 'attendee_phone' => $emailOnly ? null : '+233201111111', + 'status' => QrEventRegistration::STATUS_CONFIRMED, + ]); + + return $event; + } +}