Files
ladill-events/app/Services/Messaging/MessagingCredentialsService.php
T
isaaccladandCursor 6e42cd1cb2
Deploy Ladill Events / deploy (push) Successful in 28s
Add per-customer SMS and Bird Integrations for event messaging.
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>
2026-07-12 17:05:54 +00:00

164 lines
6.1 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?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();
}
}