Files
ladill-care/app/Services/Messaging/MessagingCredentialsService.php
T
isaaccladandCursor d13d460e32
Deploy Ladill Care / deploy (push) Successful in 58s
Add settings-gated appointment notifications and fix Bird key validation.
Patients can be SMS/email notified on booking and video schedule when enabled in Settings. Bird validation now accepts only lsk_live_ keys, strips paste artifacts, and surfaces the platform error.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-13 13:31:28 +00:00

179 lines
6.9 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;
use App\Models\Organization;
class MessagingCredentialsService
{
public function __construct(
private CustomerSmsClient $sms,
private CustomerEmailClient $email,
) {}
public function forOrganization(Organization $organization): MessagingCredential
{
return MessagingCredential::query()->firstOrCreate(
['organization_id' => $organization->id],
['owner_ref' => $organization->owner_ref],
);
}
/**
* @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;
}
}