Add per-customer SMS and Bird Integrations for event messaging.
Deploy Ladill Events / deploy (push) Successful in 28s

Attendee and speaker comms use encrypted tenant relay credentials and skip platform-key fallback and Bird wallet double-debit.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
isaacclad
2026-07-12 17:05:54 +00:00
co-authored by Cursor
parent 13b2bd9702
commit 6e42cd1cb2
21 changed files with 1095 additions and 80 deletions
+2
View File
@@ -52,6 +52,7 @@ EVENTS_API_KEY_MEET=
SMS_PLATFORM_API_URL=https://ladill.com/api SMS_PLATFORM_API_URL=https://ladill.com/api
SMS_API_KEY_EVENTS= SMS_API_KEY_EVENTS=
SMS_CUSTOMER_RELAY_URL=https://ladill.com/api/sms
SMS_DEFAULT_SENDER_ID=Ladill SMS_DEFAULT_SENDER_ID=Ladill
# Transactional email (platform mailer — same pattern as Invoice) # 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) # Legacy Bird SMTP API (unused for attendee/speaker mail; kept for future Bird integration)
SMTP_PLATFORM_API_URL=https://ladill.com/api/smtp SMTP_PLATFORM_API_URL=https://ladill.com/api/smtp
SMTP_API_KEY_EVENTS= SMTP_API_KEY_EVENTS=
SMTP_CUSTOMER_RELAY_URL=https://ladill.com/api/smtp
EVENTS_SMTP_FROM=events@ladill.com EVENTS_SMTP_FROM=events@ladill.com
EVENTS_SMTP_FROM_NAME="Ladill Events" EVENTS_SMTP_FROM_NAME="Ladill Events"
@@ -182,8 +182,8 @@ class AttendeeController extends Controller
]); ]);
$preview = $this->comms->preview($event, $validated['mode']); $preview = $this->comms->preview($event, $validated['mode']);
if (! $preview['affordable']) { if ($preview['integrations_error'] ?? null) {
return back()->with('error', 'Insufficient wallet balance. Add funds in Billing before sending.'); return back()->with('error', $preview['integrations_error']);
} }
if ($preview['recipients'] === 0) { if ($preview['recipients'] === 0) {
@@ -0,0 +1,102 @@
<?php
namespace App\Http\Controllers\Qr;
use App\Http\Controllers\Controller;
use App\Services\Messaging\CustomerSmsClient;
use App\Services\Messaging\MessagingCredentialsService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
class IntegrationsController extends Controller
{
public function edit(MessagingCredentialsService $credentials): View
{
$account = ladill_account();
return view('qr.account.integrations', [
'account' => $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'] ?? []);
}
}
+87
View File
@@ -0,0 +1,87 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\Crypt;
class MessagingCredential extends Model
{
public const STATUS_VALID = 'valid';
public const STATUS_INVALID = 'invalid';
protected $table = 'events_messaging_credentials';
protected $fillable = [
'owner_ref',
'sms_api_key_encrypted',
'sms_api_key_prefix',
'sms_sender_id',
'sms_status',
'sms_validated_at',
'sms_last_error',
'bird_api_key_encrypted',
'bird_api_key_prefix',
'bird_from_email',
'bird_from_name',
'bird_status',
'bird_validated_at',
'bird_last_error',
];
protected $hidden = [
'sms_api_key_encrypted',
'bird_api_key_encrypted',
];
protected $casts = [
'sms_validated_at' => '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);
}
}
+40 -4
View File
@@ -2,15 +2,32 @@
namespace App\Services\Billing; namespace App\Services\Billing;
use App\Services\Messaging\CustomerSmsClient;
use App\Services\Messaging\MessagingCredentialsService;
class SmsService 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); $phone = preg_replace('/\D+/', '', $to);
if (strlen($phone) < 9) { if (strlen($phone) < 9) {
return; $this->lastError = 'Enter a valid phone number.';
return false;
} }
if (strlen($phone) === 9) { if (strlen($phone) === 9) {
@@ -19,6 +36,25 @@ class SmsService
$phone = '233'.substr($phone, 1); $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;
} }
} }
+39 -8
View File
@@ -4,8 +4,8 @@ namespace App\Services\Events;
use App\Models\QrCode; use App\Models\QrCode;
use App\Models\QrEventRegistration; use App\Models\QrEventRegistration;
use App\Services\Billing\BillingClient;
use App\Services\Billing\SmsService; use App\Services\Billing\SmsService;
use App\Services\Messaging\MessagingCredentialsService;
use Illuminate\Support\Collection; use Illuminate\Support\Collection;
class EventCommsService class EventCommsService
@@ -21,11 +21,11 @@ class EventCommsService
public function __construct( public function __construct(
private readonly EventEmailService $email, private readonly EventEmailService $email,
private readonly SmsService $sms, private readonly SmsService $sms,
private readonly BillingClient $billing, private readonly MessagingCredentialsService $credentials,
private readonly EventProgrammeService $programmes, 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 public function preview(QrCode $event, string $mode): array
{ {
$regs = $this->confirmedRegistrations($event); $regs = $this->confirmedRegistrations($event);
@@ -34,14 +34,15 @@ class EventCommsService
$smsSegments = $phones * $this->estimateSmsSegments($event, $mode); $smsSegments = $phones * $this->estimateSmsSegments($event, $mode);
$estimated = round(($emails * self::EMAIL_PRICE_GHS) + ($smsSegments * self::SMS_PRICE_GHS), 2); $estimated = round(($emails * self::EMAIL_PRICE_GHS) + ($smsSegments * self::SMS_PRICE_GHS), 2);
$ownerRef = (string) $event->user->public_id; $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 [ return [
'recipients' => $regs->count(), 'recipients' => $regs->count(),
'emails' => $emails, 'emails' => $emails,
'sms' => $phones, 'sms' => $phones,
'estimated_ghs' => $estimated, '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 public function send(QrCode $event, string $mode): array
{ {
$preview = $this->preview($event, $mode); $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); $programme = $this->programmeFor($event);
$eventName = $event->content()['name'] ?? $event->label; $eventName = $event->content()['name'] ?? $event->label;
@@ -86,8 +87,7 @@ class EventCommsService
if ($reg->attendee_phone) { if ($reg->attendee_phone) {
$message = $this->smsBody($reg, $eventName, $mode, $programmeUrl, $joinUrl); $message = $this->smsBody($reg, $eventName, $mode, $programmeUrl, $joinUrl);
if ($message !== '') { if ($message !== '' && $this->sms->send($ownerRef, $reg->attendee_phone, $message)) {
$this->sms->send($ownerRef, $reg->attendee_phone, $message);
$texted++; $texted++;
$sent = true; $sent = true;
} }
@@ -167,4 +167,35 @@ class EventCommsService
return max(1, (int) ceil(strlen($sample) / self::SMS_SEGMENT_CHARS)); 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;
}
} }
+31 -48
View File
@@ -2,31 +2,36 @@
namespace App\Services\Events; namespace App\Services\Events;
use App\Services\Billing\BillingClient; use App\Services\Messaging\CustomerEmailClient;
use Illuminate\Support\Facades\Log; use App\Services\Messaging\MessagingCredentialsService;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Str;
/** /**
* Sends Ladill Events transactional email via the platform mailer * Sends Ladill Events transactional email to attendees and speakers via the
* (noreply@ladill.com same as CRM and other Ladill apps). * customer's Ladill Bird relay when configured in Integrations.
*/ */
class EventMailer class EventMailer
{ {
public const EMAIL_PRICE_MINOR = 1;
private ?string $lastError = null; 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 public function lastError(): ?string
{ {
return $this->lastError; 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 public function isConfigured(): bool
{ {
return filter_var((string) config('mail.from.address'), FILTER_VALIDATE_EMAIL) !== false; return false;
} }
public function send( public function send(
@@ -40,54 +45,32 @@ class EventMailer
): bool { ): bool {
$this->lastError = null; $this->lastError = null;
if (! $this->isConfigured()) { $credential = $this->credentials->forOwner($ownerPublicId);
$this->lastError = 'Outbound email is not configured on this Events instance.'; if (! $credential->hasValidBird()) {
$this->lastError = 'Connect Ladill Bird in Integrations before sending attendee email.';
return false; return false;
} }
if (! $this->billing->canAfford($ownerPublicId, self::EMAIL_PRICE_MINOR)) { $apiKey = $credential->birdApiKey();
$this->lastError = 'Insufficient Ladill wallet balance. Add funds in Billing and try again.'; if (! $apiKey) {
$this->lastError = 'Bird credentials could not be decrypted. Reconnect Bird in Integrations.';
return false; return false;
} }
$reference = 'events_email:'.Str::uuid(); $sent = $this->customerEmail->send(
$apiKey,
try { (string) $credential->bird_from_email,
Mail::html($html, function ($message) use ($to, $subject, $text, $replyToEmail, $replyToName): void { $credential->bird_from_name,
$message->to($to) $to,
->subject($subject) $subject,
->from( $html,
(string) config('mail.from.address'), $text,
(string) config('mail.from.name'),
); );
if ($replyToEmail) { if (! $sent) {
$message->replyTo($replyToEmail, $replyToName ?: null); $this->lastError = $this->customerEmail->lastError() ?: 'The email could not be sent.';
}
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.';
return false; return false;
} }
@@ -226,8 +226,8 @@ class EventSpeakerInviteService
return ['ok' => false, 'error' => 'Could not resolve the event owner account for sending email.']; return ['ok' => false, 'error' => 'Could not resolve the event owner account for sending email.'];
} }
if (! app(EventMailer::class)->isConfigured()) { if (! app(EventMailer::class)->isConfiguredForOwner($ownerRef)) {
return ['ok' => false, 'error' => 'Outbound email is not configured on this Events instance.']; return ['ok' => false, 'error' => 'Connect Ladill Bird in Integrations before sending speaker invitations.'];
} }
$token = (string) ($speaker['invite_token'] ?? ''); $token = (string) ($speaker['invite_token'] ?? '');
@@ -0,0 +1,92 @@
<?php
namespace App\Services\Messaging;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
class CustomerEmailClient
{
private ?string $lastError = null;
public function lastError(): ?string
{
return $this->lastError;
}
private function base(): string
{
return rtrim((string) config('smtp.customer_relay_url', 'https://ladill.com/api/smtp'), '/');
}
/** @return array{ok: bool, data?: array<string, mixed>, 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;
}
}
}
@@ -0,0 +1,94 @@
<?php
namespace App\Services\Messaging;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
class CustomerSmsClient
{
private ?string $lastError = null;
public function lastError(): ?string
{
return $this->lastError;
}
private function base(): string
{
return rtrim((string) config('sms.customer_relay_url', 'https://ladill.com/api/sms'), '/');
}
/** @return array{ok: bool, data?: array<string, mixed>, error?: string, status?: int} */
public function me(string $apiKey): array
{
return $this->get($apiKey, '/me');
}
/** @return array{ok: bool, data?: array<string, mixed>, 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<string, mixed>, 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.'];
}
}
}
@@ -0,0 +1,163 @@
<?php
namespace App\Services\Messaging;
use App\Models\MessagingCredential;
class MessagingCredentialsService
{
public function __construct(
private CustomerSmsClient $sms,
private CustomerEmailClient $email,
) {}
public function forOwner(string $ownerRef): MessagingCredential
{
return MessagingCredential::query()->firstOrCreate(
['owner_ref' => $ownerRef],
);
}
/**
* @return array{ok: bool, error?: string, credential?: MessagingCredential, senders?: list<string>}
*/
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 111 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();
}
}
+1
View File
@@ -3,5 +3,6 @@
return [ return [
'platform_api_url' => env('SMS_PLATFORM_API_URL', 'https://ladill.com/api'), 'platform_api_url' => env('SMS_PLATFORM_API_URL', 'https://ladill.com/api'),
'platform_api_key' => env('SMS_API_KEY_EVENTS'), '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'), 'default_sender_id' => env('SMS_DEFAULT_SENDER_ID', 'Ladill'),
]; ];
+1
View File
@@ -3,6 +3,7 @@
return [ return [
'platform_api_url' => env('SMTP_PLATFORM_API_URL', 'https://ladill.com/api/smtp'), 'platform_api_url' => env('SMTP_PLATFORM_API_URL', 'https://ladill.com/api/smtp'),
'platform_api_key' => env('SMTP_API_KEY_EVENTS'), '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' => env('EVENTS_SMTP_FROM', 'events@ladill.com'),
'from_name' => env('EVENTS_SMTP_FROM_NAME', 'Ladill Events'), 'from_name' => env('EVENTS_SMTP_FROM_NAME', 'Ladill Events'),
]; ];
@@ -0,0 +1,35 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('events_messaging_credentials', function (Blueprint $table) {
$table->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');
}
};
+2 -2
View File
@@ -54,12 +54,12 @@
</span> </span>
<div> <div>
<p class="text-sm font-semibold text-slate-900">Message attendees</p> <p class="text-sm font-semibold text-slate-900">Message attendees</p>
<p class="text-xs text-slate-500">Send programme links, virtual join links, or both. SMS and email are billed from your wallet.</p> <p class="text-xs text-slate-500">Send programme links, virtual join links, or both. Uses your connected Ladill SMS and Bird keys. <a href="{{ route('account.integrations') }}" class="font-medium text-indigo-600 hover:text-indigo-800">Manage integrations</a></p>
<template x-if="preview"> <template x-if="preview">
<p class="mt-2 text-xs text-slate-600"> <p class="mt-2 text-xs text-slate-600">
<span x-text="preview.recipients"></span> recipients · <span x-text="preview.recipients"></span> recipients ·
est. GHS <span x-text="preview.estimated_ghs.toFixed(2)"></span> est. GHS <span x-text="preview.estimated_ghs.toFixed(2)"></span>
<span x-show="!preview.affordable" class="font-semibold text-red-600"> insufficient balance</span> <span x-show="preview.integrations_error" class="font-semibold text-red-600" x-text="' — ' + preview.integrations_error"></span>
</p> </p>
</template> </template>
</div> </div>
+1
View File
@@ -1,5 +1,6 @@
@php @php
$mobileFullScreenPage = request()->routeIs('account.settings') $mobileFullScreenPage = request()->routeIs('account.settings')
|| request()->routeIs('account.integrations')
|| request()->routeIs('events.create') || request()->routeIs('events.create')
|| request()->routeIs('programmes.create') || request()->routeIs('programmes.create')
|| request()->routeIs('events.show') || request()->routeIs('events.show')
@@ -0,0 +1,122 @@
<x-user-layout>
<x-slot name="title">Integrations</x-slot>
<div class="mx-auto max-w-2xl">
<h1 class="text-xl font-semibold tracking-tight text-slate-900">Messaging integrations</h1>
<p class="mt-0.5 text-sm text-slate-500">
Connect your own
<a href="https://sms.ladill.com" target="_blank" rel="noopener" class="font-medium text-indigo-600 hover:text-indigo-800">Ladill SMS</a>
and
<a href="https://bird.ladill.com" target="_blank" rel="noopener" class="font-medium text-indigo-600 hover:text-indigo-800">Ladill Bird</a>
API keys so attendee messages bill your wallets and send under your brand.
</p>
@if (session('success'))
<div class="mt-4 rounded-xl border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm text-emerald-800">{{ session('success') }}</div>
@endif
@if (session('error'))
<div class="mt-4 rounded-xl border border-rose-200 bg-rose-50 px-4 py-3 text-sm text-rose-800">{{ session('error') }}</div>
@endif
<div class="mt-6 space-y-4">
<div class="rounded-2xl border border-slate-200 bg-white p-5">
<h2 class="text-sm font-semibold text-slate-900">Ladill SMS</h2>
<p class="mt-1 text-xs text-slate-500">Paste an SMS API key (lsk_sms_live_…) and an approved sender ID from sms.ladill.com.</p>
@if ($credential->hasValidSms())
<div class="mt-4 rounded-xl border border-emerald-200 bg-emerald-50 px-3 py-2 text-sm text-emerald-800">
Connected · key {{ $credential->sms_api_key_prefix }} · sender <strong>{{ $credential->sms_sender_id }}</strong>
@if ($credential->sms_validated_at)
· validated {{ $credential->sms_validated_at->diffForHumans() }}
@endif
</div>
@elseif ($credential->sms_status === \App\Models\MessagingCredential::STATUS_INVALID)
<div class="mt-4 rounded-xl border border-rose-200 bg-rose-50 px-3 py-2 text-sm text-rose-800">
{{ $credential->sms_last_error ?: 'SMS credentials are invalid.' }}
</div>
@endif
<form method="POST" action="{{ route('account.integrations.sms.save') }}" class="mt-4 space-y-4">
@csrf
<div>
<label class="block text-[11px] font-medium text-slate-500">SMS API key</label>
<input type="password" name="sms_api_key" autocomplete="off" required
placeholder="lsk_sms_live_…"
class="mt-1 w-full rounded-lg border border-slate-200 bg-slate-50 px-3 py-2 font-mono text-sm focus:border-indigo-300 focus:bg-white focus:outline-none focus:ring-2 focus:ring-indigo-100">
</div>
<div>
<label class="block text-[11px] font-medium text-slate-500">Sender ID</label>
<input type="text" name="sms_sender_id" maxlength="11" required
value="{{ old('sms_sender_id', $credential->sms_sender_id) }}"
placeholder="Approved sender ID"
class="mt-1 w-full rounded-lg border border-slate-200 bg-slate-50 px-3 py-2 text-sm focus:border-indigo-300 focus:bg-white focus:outline-none focus:ring-2 focus:ring-indigo-100">
<p class="mt-1 text-[11px] text-slate-400">Must match an approved sender ID on your SMS service (or the platform default).</p>
</div>
<button type="submit" class="btn-primary">Validate &amp; save</button>
</form>
@if ($credential->sms_api_key_encrypted || $credential->hasValidSms())
<form method="POST" action="{{ route('account.integrations.sms.disconnect') }}" class="mt-3" onsubmit="return confirm('Disconnect Ladill SMS?')">
@csrf
<button type="submit" class="text-sm text-rose-700 hover:underline">Disconnect SMS</button>
</form>
@endif
</div>
<div class="rounded-2xl border border-slate-200 bg-white p-5">
<h2 class="text-sm font-semibold text-slate-900">Ladill Bird</h2>
<p class="mt-1 text-xs text-slate-500">Paste a Bird API key (lsk_live_…) plus a from address on a verified domain from bird.ladill.com.</p>
@if ($credential->hasValidBird())
<div class="mt-4 rounded-xl border border-emerald-200 bg-emerald-50 px-3 py-2 text-sm text-emerald-800">
Connected · key {{ $credential->bird_api_key_prefix }} · from
<strong>{{ $credential->bird_from_name ? $credential->bird_from_name.' <'.$credential->bird_from_email.'>' : $credential->bird_from_email }}</strong>
@if ($credential->bird_validated_at)
· validated {{ $credential->bird_validated_at->diffForHumans() }}
@endif
</div>
@elseif ($credential->bird_status === \App\Models\MessagingCredential::STATUS_INVALID)
<div class="mt-4 rounded-xl border border-rose-200 bg-rose-50 px-3 py-2 text-sm text-rose-800">
{{ $credential->bird_last_error ?: 'Bird credentials are invalid.' }}
</div>
@endif
<form method="POST" action="{{ route('account.integrations.bird.save') }}" class="mt-4 space-y-4">
@csrf
<div>
<label class="block text-[11px] font-medium text-slate-500">Bird API key</label>
<input type="password" name="bird_api_key" autocomplete="off" required
placeholder="lsk_live_…"
class="mt-1 w-full rounded-lg border border-slate-200 bg-slate-50 px-3 py-2 font-mono text-sm focus:border-indigo-300 focus:bg-white focus:outline-none focus:ring-2 focus:ring-indigo-100">
</div>
<div>
<label class="block text-[11px] font-medium text-slate-500">From email</label>
<input type="email" name="bird_from_email" required
value="{{ old('bird_from_email', $credential->bird_from_email) }}"
placeholder="events@yourdomain.com"
class="mt-1 w-full rounded-lg border border-slate-200 bg-slate-50 px-3 py-2 text-sm focus:border-indigo-300 focus:bg-white focus:outline-none focus:ring-2 focus:ring-indigo-100">
</div>
<div>
<label class="block text-[11px] font-medium text-slate-500">From name</label>
<input type="text" name="bird_from_name" maxlength="100"
value="{{ old('bird_from_name', $credential->bird_from_name ?: $account->name) }}"
placeholder="{{ $account->name }}"
class="mt-1 w-full rounded-lg border border-slate-200 bg-slate-50 px-3 py-2 text-sm focus:border-indigo-300 focus:bg-white focus:outline-none focus:ring-2 focus:ring-indigo-100">
</div>
<button type="submit" class="btn-primary">Validate &amp; save</button>
</form>
@if ($credential->bird_api_key_encrypted || $credential->hasValidBird())
<form method="POST" action="{{ route('account.integrations.bird.disconnect') }}" class="mt-3" onsubmit="return confirm('Disconnect Ladill Bird?')">
@csrf
<button type="submit" class="text-sm text-rose-700 hover:underline">Disconnect Bird</button>
</form>
@endif
</div>
</div>
<p class="mt-6 text-xs text-slate-400">
<a href="{{ route('account.settings') }}" class="font-medium text-indigo-600 hover:text-indigo-800"> Back to settings</a>
</p>
</div>
</x-user-layout>
@@ -252,6 +252,12 @@
</button> </button>
</form> </form>
<div class="mt-4 rounded-2xl border border-slate-200 bg-white p-5">
<h2 class="text-sm font-semibold text-slate-900">Messaging integrations</h2>
<p class="mt-1 text-xs text-slate-500">Connect your Ladill SMS and Bird API keys for attendee and speaker messaging.</p>
<a href="{{ route('account.integrations') }}" class="mt-3 inline-block text-sm font-semibold text-indigo-600 hover:text-indigo-800">Open messaging integrations </a>
</div>
<p class="mt-6 text-xs text-slate-400"> <p class="mt-6 text-xs text-slate-400">
Profile, password, and security are managed on Profile, password, and security are managed on
<a href="{{ ladill_account_url('account-settings') }}" class="font-medium text-indigo-600 hover:text-indigo-800">account.ladill.com</a>. <a href="{{ ladill_account_url('account-settings') }}" class="font-medium text-indigo-600 hover:text-indigo-800">account.ladill.com</a>.
+7
View File
@@ -11,6 +11,7 @@ use App\Http\Controllers\NotificationController;
use App\Http\Controllers\Public\EventRegistrationController; use App\Http\Controllers\Public\EventRegistrationController;
use App\Http\Controllers\Public\QrScanController; use App\Http\Controllers\Public\QrScanController;
use App\Http\Controllers\Qr\AccountController; use App\Http\Controllers\Qr\AccountController;
use App\Http\Controllers\Qr\IntegrationsController;
use App\Http\Controllers\Qr\AfiaController; use App\Http\Controllers\Qr\AfiaController;
use App\Http\Controllers\Qr\DeveloperController; use App\Http\Controllers\Qr\DeveloperController;
use App\Http\Controllers\Events\CustomDomainController; 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('/billing', fn () => redirect()->away(ladill_account_url('/billing')))->name('account.billing');
Route::get('/settings', [AccountController::class, 'settings'])->name('account.settings'); Route::get('/settings', [AccountController::class, 'settings'])->name('account.settings');
Route::put('/settings', [AccountController::class, 'updateSettings'])->name('account.settings.update'); 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::get('/team', fn () => redirect()->away(ladill_account_url('/account/team')))->name('account.team');
Route::post('/switch-account', [TeamController::class, 'switchAccount'])->name('account.switch'); Route::post('/switch-account', [TeamController::class, 'switchAccount'])->name('account.switch');
+23 -13
View File
@@ -2,12 +2,13 @@
namespace Tests\Feature; namespace Tests\Feature;
use App\Models\MessagingCredential;
use App\Models\QrCode; use App\Models\QrCode;
use App\Models\User; use App\Models\User;
use App\Services\Events\EventSpeakerInviteService; use App\Services\Events\EventSpeakerInviteService;
use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Crypt;
use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Mail;
use Tests\TestCase; use Tests\TestCase;
class EventSpeakerInviteTest extends TestCase class EventSpeakerInviteTest extends TestCase
@@ -20,17 +21,25 @@ class EventSpeakerInviteTest extends TestCase
$this->withoutMiddleware(\App\Http\Middleware\EnsurePlatformSession::class); $this->withoutMiddleware(\App\Http\Middleware\EnsurePlatformSession::class);
config([ config([
'mail.from.address' => 'noreply@ladill.com',
'mail.from.name' => 'Ladill',
'events.service_api_keys.meet' => 'test-meet-key', 'events.service_api_keys.meet' => 'test-meet-key',
]); ]);
Mail::fake();
Http::fake([ Http::fake([
config('billing.api_url').'/balance*' => Http::response(['balance_minor' => 50000]), '*/api/smtp/send' => Http::response(['success' => true, 'message_id' => 'x']),
config('billing.api_url').'/can-afford*' => Http::response(['affordable' => true]), ]);
config('billing.api_url').'/debit' => Http::response(['success' => true]), }
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 public function test_manual_speaker_invite_sends_email_and_records_metadata(): void
{ {
$owner = User::factory()->create(['public_id' => 'usr_sp_manual']); $owner = User::factory()->create(['public_id' => 'usr_sp_manual']);
$this->seedBirdCredentials($owner);
$event = QrCode::create([ $event = QrCode::create([
'user_id' => $owner->id, 'user_id' => $owner->id,
@@ -103,7 +113,7 @@ class EventSpeakerInviteTest extends TestCase
->assertRedirect(route('speakers.show', $event)) ->assertRedirect(route('speakers.show', $event))
->assertSessionHas('success'); ->assertSessionHas('success');
Http::assertSent(fn ($request) => str_contains($request->url(), '/debit')); Http::assertSent(fn ($request) => str_contains($request->url(), '/api/smtp/send'));
$event->refresh(); $event->refresh();
$speaker = $event->content()['speakers'][0]; $speaker = $event->content()['speakers'][0];
@@ -115,6 +125,7 @@ class EventSpeakerInviteTest extends TestCase
public function test_programme_save_invites_assigned_speaker_hosts(): void public function test_programme_save_invites_assigned_speaker_hosts(): void
{ {
$owner = User::factory()->create(['public_id' => 'usr_sp_prog']); $owner = User::factory()->create(['public_id' => 'usr_sp_prog']);
$this->seedBirdCredentials($owner);
$programme = QrCode::create([ $programme = QrCode::create([
'user_id' => $owner->id, 'user_id' => $owner->id,
@@ -166,7 +177,7 @@ class EventSpeakerInviteTest extends TestCase
app(EventSpeakerInviteService::class)->inviteProgrammeHosts($programme, $owner); 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(); $event->refresh();
$speaker = $event->content()['speakers'][0]; $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 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']); $owner = User::factory()->create(['public_id' => 'usr_sp_no_smtp']);
$event = QrCode::create([ $event = QrCode::create([
@@ -266,6 +275,7 @@ class EventSpeakerInviteTest extends TestCase
public function test_manual_speaker_invite_can_be_resent_after_programme_invite(): void public function test_manual_speaker_invite_can_be_resent_after_programme_invite(): void
{ {
$owner = User::factory()->create(['public_id' => 'usr_sp_resend']); $owner = User::factory()->create(['public_id' => 'usr_sp_resend']);
$this->seedBirdCredentials($owner);
$event = QrCode::create([ $event = QrCode::create([
'user_id' => $owner->id, 'user_id' => $owner->id,
@@ -296,6 +306,6 @@ class EventSpeakerInviteTest extends TestCase
->assertRedirect(route('speakers.show', $event)) ->assertRedirect(route('speakers.show', $event))
->assertSessionHas('success'); ->assertSessionHas('success');
Http::assertSent(fn ($request) => str_contains($request->url(), '/debit')); Http::assertSent(fn ($request) => str_contains($request->url(), '/api/smtp/send'));
} }
} }
@@ -0,0 +1,242 @@
<?php
namespace Tests\Feature;
use App\Http\Middleware\EnsurePlatformSession;
use App\Models\MessagingCredential;
use App\Models\QrCode;
use App\Models\QrEventRegistration;
use App\Models\User;
use App\Services\Events\EventCommsService;
use App\Services\Events\EventEmailService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Crypt;
use Illuminate\Support\Facades\Http;
use Tests\TestCase;
class EventsMessagingIntegrationsTest extends TestCase
{
use RefreshDatabase;
protected User $user;
protected function setUp(): void
{
parent::setUp();
$this->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;
}
}