Files
ladill-care/app/Services/Messaging/MessagingCredentialsService.php
isaaccladandCursor 640b62010a
Deploy Ladill Care / deploy (push) Successful in 52s
Fix Care settings 500s and slow saves under Queue/demo load.
Defer Queue provision and ticket backfill after the settings response, harden messaging credential creation against lock races, and stop wrapping demo seed in a long-held transaction.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-17 22:57:25 +00:00

199 lines
7.6 KiB
PHP
Raw Permalink 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;
use App\Models\Organization;
class MessagingCredentialsService
{
public function __construct(
private CustomerSmsClient $sms,
private CustomerEmailClient $email,
) {}
public function forOrganization(Organization $organization): MessagingCredential
{
$existing = MessagingCredential::query()
->where('organization_id', $organization->id)
->first();
if ($existing) {
return $existing;
}
try {
return MessagingCredential::query()->create([
'organization_id' => $organization->id,
'owner_ref' => $organization->owner_ref,
]);
} catch (\Throwable $e) {
// Concurrent create (or a long demo-seed transaction holding a lock)
// — prefer returning the row another request just wrote over 500ing.
$existing = MessagingCredential::query()
->where('organization_id', $organization->id)
->first();
if ($existing) {
return $existing;
}
throw $e;
}
}
/**
* @return array{ok: bool, error?: string, credential?: MessagingCredential, senders?: list<string>}
*/
public function validateAndSaveSms(Organization $organization, string $apiKey, string $senderId): array
{
$apiKey = $this->sanitizeApiKey($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->forOrganization($organization);
$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->forOrganization($organization);
$credential->update([
'sms_status' => MessagingCredential::STATUS_INVALID,
'sms_last_error' => $error,
]);
return ['ok' => false, 'error' => $error, 'credential' => $credential, 'senders' => $allowed];
}
$credential = $this->forOrganization($organization);
$credential->update([
'owner_ref' => $organization->owner_ref,
'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(Organization $organization, string $apiKey, string $fromEmail, ?string $fromName): array
{
$apiKey = $this->sanitizeApiKey($apiKey);
$fromEmail = strtolower(trim($fromEmail));
$fromName = trim((string) $fromName);
// Platform SMTP relay only accepts HTTP API keys issued as lsk_live_…
if (! str_starts_with($apiKey, 'lsk_live_')) {
return ['ok' => false, 'error' => 'Bird API keys must start with lsk_live_. Copy the HTTP API key from bird.ladill.com (not the mailbox password).'];
}
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)) {
$error = $me['error'] ?? 'Invalid Bird API key.';
$credential = $this->forOrganization($organization);
$credential->update([
'bird_status' => MessagingCredential::STATUS_INVALID,
'bird_last_error' => $error,
]);
return ['ok' => false, 'error' => $error, '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->forOrganization($organization);
$credential->update([
'bird_status' => MessagingCredential::STATUS_INVALID,
'bird_last_error' => $error,
]);
return ['ok' => false, 'error' => $error, 'credential' => $credential];
}
$credential = $this->forOrganization($organization);
$credential->update([
'owner_ref' => $organization->owner_ref,
'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(Organization $organization): MessagingCredential
{
$credential = $this->forOrganization($organization);
$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(Organization $organization): MessagingCredential
{
$credential = $this->forOrganization($organization);
$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();
}
private function sanitizeApiKey(string $apiKey): string
{
$apiKey = trim($apiKey);
// Strip zero-width / BOM / whitespace that often sneaks in on paste.
$cleaned = preg_replace('/[\x{200B}-\x{200D}\x{FEFF}\s]+/u', '', $apiKey);
return is_string($cleaned) ? $cleaned : $apiKey;
}
}