diff --git a/.env.example b/.env.example index 1754d45..e2763af 100644 --- a/.env.example +++ b/.env.example @@ -44,14 +44,16 @@ IDENTITY_API_KEY_CARE= MEET_API_URL=https://meet.ladill.com/api/service/v1 MEET_API_KEY_CARE= -# Ladill SMS (patient messaging — management API on ladill.com) +# Ladill SMS (patient messaging uses customer keys via Integrations; platform key optional) SMS_PLATFORM_API_URL=https://ladill.com/api SMS_API_KEY_CARE= +SMS_CUSTOMER_RELAY_URL=https://ladill.com/api/sms SMS_DEFAULT_SENDER_ID=Ladill -# Ladill Bird / SMTP (patient email — management API on ladill.com) +# Ladill Bird / SMTP (patient email uses customer keys via Integrations) SMTP_PLATFORM_API_URL=https://ladill.com/api/smtp SMTP_API_KEY_CARE= +SMTP_CUSTOMER_RELAY_URL=https://ladill.com/api/smtp CARE_SMTP_FROM=care@ladill.com CARE_SMTP_FROM_NAME="Ladill Care" diff --git a/app/Http/Controllers/Care/IntegrationsController.php b/app/Http/Controllers/Care/IntegrationsController.php new file mode 100644 index 0000000..7223ce6 --- /dev/null +++ b/app/Http/Controllers/Care/IntegrationsController.php @@ -0,0 +1,152 @@ +authorizeAbility($request, 'settings.view'); + $organization = $this->organization($request); + $canManage = app(CarePermissions::class)->can($this->member($request), 'settings.manage'); + + return view('care.integrations.edit', [ + 'organization' => $organization, + 'canManage' => $canManage, + 'credential' => $credentials->forOrganization($organization), + ]); + } + + public function saveSms(Request $request, MessagingCredentialsService $credentials): RedirectResponse + { + $this->authorizeAbility($request, 'settings.manage'); + $organization = $this->organization($request); + + $data = $request->validate([ + 'sms_api_key' => ['required', 'string', 'max:200'], + 'sms_sender_id' => ['required', 'string', 'max:11'], + ]); + + $result = $credentials->validateAndSaveSms( + $organization, + $data['sms_api_key'], + $data['sms_sender_id'], + ); + + AuditLogger::record( + $this->ownerRef($request), + ($result['ok'] ?? false) ? 'integrations.sms_connected' : 'integrations.sms_failed', + $organization->id, + $this->ownerRef($request), + \App\Models\Organization::class, + $organization->id, + ['prefix' => substr(trim($data['sms_api_key']), 0, 16)], + ); + + if (! ($result['ok'] ?? false)) { + return back()->withInput()->with('error', $result['error'] ?? 'Could not save SMS credentials.'); + } + + return back()->with('success', 'Ladill SMS connected. Outbound patient SMS will use your key and sender ID.'); + } + + public function disconnectSms(Request $request, MessagingCredentialsService $credentials): RedirectResponse + { + $this->authorizeAbility($request, 'settings.manage'); + $organization = $this->organization($request); + $credentials->disconnectSms($organization); + + AuditLogger::record( + $this->ownerRef($request), + 'integrations.sms_disconnected', + $organization->id, + $this->ownerRef($request), + \App\Models\Organization::class, + $organization->id, + ); + + return back()->with('success', 'Ladill SMS disconnected.'); + } + + public function saveBird(Request $request, MessagingCredentialsService $credentials): RedirectResponse + { + $this->authorizeAbility($request, 'settings.manage'); + $organization = $this->organization($request); + + $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( + $organization, + $data['bird_api_key'], + $data['bird_from_email'], + $data['bird_from_name'] ?? null, + ); + + AuditLogger::record( + $this->ownerRef($request), + ($result['ok'] ?? false) ? 'integrations.bird_connected' : 'integrations.bird_failed', + $organization->id, + $this->ownerRef($request), + \App\Models\Organization::class, + $organization->id, + ['prefix' => substr(trim($data['bird_api_key']), 0, 16)], + ); + + if (! ($result['ok'] ?? false)) { + return back()->withInput()->with('error', $result['error'] ?? 'Could not save Bird credentials.'); + } + + return back()->with('success', 'Ladill Bird connected. Outbound patient email will use your key and from address.'); + } + + public function disconnectBird(Request $request, MessagingCredentialsService $credentials): RedirectResponse + { + $this->authorizeAbility($request, 'settings.manage'); + $organization = $this->organization($request); + $credentials->disconnectBird($organization); + + AuditLogger::record( + $this->ownerRef($request), + 'integrations.bird_disconnected', + $organization->id, + $this->ownerRef($request), + \App\Models\Organization::class, + $organization->id, + ); + + return back()->with('success', 'Ladill Bird disconnected.'); + } + + public function previewSenders(Request $request, CustomerSmsClient $sms): JsonResponse + { + $this->authorizeAbility($request, 'settings.manage'); + + $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/Http/Controllers/Care/PatientController.php b/app/Http/Controllers/Care/PatientController.php index 4885ab3..67bc557 100644 --- a/app/Http/Controllers/Care/PatientController.php +++ b/app/Http/Controllers/Care/PatientController.php @@ -93,9 +93,13 @@ class PatientController extends Controller $dashboard = $this->patients->dashboard($patient); $canManage = app(\App\Services\Care\CarePermissions::class) ->can($this->member($request), 'patients.manage'); + $credential = app(\App\Services\Messaging\MessagingCredentialsService::class) + ->forOrganization($this->organization($request)); return view('care.patients.show', array_merge($dashboard, [ 'canManage' => $canManage, + 'messagingSmsReady' => $credential->hasValidSms(), + 'messagingEmailReady' => $credential->hasValidBird(), 'genders' => config('care.genders'), 'allergySeverities' => config('care.allergy_severities'), 'documentTypes' => config('care.document_types'), diff --git a/app/Http/Controllers/Care/PatientMessageController.php b/app/Http/Controllers/Care/PatientMessageController.php index 1ad2072..7d1d942 100644 --- a/app/Http/Controllers/Care/PatientMessageController.php +++ b/app/Http/Controllers/Care/PatientMessageController.php @@ -5,10 +5,11 @@ namespace App\Http\Controllers\Care; use App\Http\Controllers\Controller; use App\Http\Controllers\Care\Concerns\ScopesToAccount; use App\Models\Patient; -use App\Services\Billing\PlatformEmailClient; -use App\Services\Billing\PlatformSmsClient; use App\Services\Care\AuditLogger; use App\Services\Care\OrganizationResolver; +use App\Services\Messaging\CustomerEmailClient; +use App\Services\Messaging\CustomerSmsClient; +use App\Services\Messaging\MessagingCredentialsService; use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; @@ -16,8 +17,12 @@ class PatientMessageController extends Controller { use ScopesToAccount; - public function sendEmail(Request $request, Patient $patient, PlatformEmailClient $email): RedirectResponse - { + public function sendEmail( + Request $request, + Patient $patient, + MessagingCredentialsService $credentials, + CustomerEmailClient $email, + ): RedirectResponse { $this->authorizeAbility($request, 'patients.manage'); $this->authorizePatient($request, $patient); @@ -25,6 +30,16 @@ class PatientMessageController extends Controller return back()->with('error', 'This patient has no valid email on file.'); } + $credential = $credentials->forOrganization($this->organization($request)); + if (! $credential->hasValidBird()) { + return back()->with('error', 'Connect Ladill Bird in Integrations before sending patient email.'); + } + + $apiKey = $credential->birdApiKey(); + if (! $apiKey) { + return back()->with('error', 'Bird credentials could not be decrypted. Reconnect Bird in Integrations.'); + } + $data = $request->validate([ 'subject' => ['required', 'string', 'max:200'], 'body' => ['required', 'string', 'max:5000'], @@ -32,7 +47,15 @@ class PatientMessageController extends Controller $owner = $this->ownerRef($request); $html = nl2br(e($data['body'])); - $sent = $email->send($owner, (string) $patient->email, $data['subject'], $html, $data['body']); + $sent = $email->send( + $apiKey, + (string) $credential->bird_from_email, + $credential->bird_from_name, + (string) $patient->email, + $data['subject'], + $html, + $data['body'], + ); AuditLogger::record( $owner, @@ -55,8 +78,12 @@ class PatientMessageController extends Controller return back()->with('success', 'Email sent to '.$patient->email); } - public function sendSms(Request $request, Patient $patient, PlatformSmsClient $sms): RedirectResponse - { + public function sendSms( + Request $request, + Patient $patient, + MessagingCredentialsService $credentials, + CustomerSmsClient $sms, + ): RedirectResponse { $this->authorizeAbility($request, 'patients.manage'); $this->authorizePatient($request, $patient); @@ -64,12 +91,22 @@ class PatientMessageController extends Controller return back()->with('error', 'This patient has no phone number on file.'); } + $credential = $credentials->forOrganization($this->organization($request)); + if (! $credential->hasValidSms()) { + return back()->with('error', 'Connect Ladill SMS in Integrations before sending patient SMS.'); + } + + $apiKey = $credential->smsApiKey(); + if (! $apiKey) { + return back()->with('error', 'SMS credentials could not be decrypted. Reconnect SMS in Integrations.'); + } + $data = $request->validate([ 'message' => ['required', 'string', 'max:480'], ]); $owner = $this->ownerRef($request); - $sent = $sms->send($owner, (string) $patient->phone, $data['message']); + $sent = $sms->send($apiKey, (string) $patient->phone, $data['message'], (string) $credential->sms_sender_id); AuditLogger::record( $owner, diff --git a/app/Models/MessagingCredential.php b/app/Models/MessagingCredential.php new file mode 100644 index 0000000..26b7480 --- /dev/null +++ b/app/Models/MessagingCredential.php @@ -0,0 +1,97 @@ + 'datetime', + 'bird_validated_at' => 'datetime', + ]; + + public function organization(): BelongsTo + { + return $this->belongsTo(Organization::class); + } + + 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/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..f5e2e57 --- /dev/null +++ b/app/Services/Messaging/MessagingCredentialsService.php @@ -0,0 +1,167 @@ +firstOrCreate( + ['organization_id' => $organization->id], + ['owner_ref' => $organization->owner_ref], + ); + } + + /** + * @return array{ok: bool, error?: string, credential?: MessagingCredential, senders?: list} + */ + public function validateAndSaveSms(Organization $organization, 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->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 = 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->forOrganization($organization); + $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->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(); + } +} diff --git a/config/sms.php b/config/sms.php index ab1e786..53ff5c7 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_CARE'), + '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 9b4fb27..580eaf7 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_CARE'), + 'customer_relay_url' => env('SMTP_CUSTOMER_RELAY_URL', 'https://ladill.com/api/smtp'), 'from' => env('CARE_SMTP_FROM', 'care@ladill.com'), 'from_name' => env('CARE_SMTP_FROM_NAME', 'Ladill Care'), ]; diff --git a/database/migrations/2026_07_12_100000_create_care_messaging_credentials_table.php b/database/migrations/2026_07_12_100000_create_care_messaging_credentials_table.php new file mode 100644 index 0000000..ed64d0e --- /dev/null +++ b/database/migrations/2026_07_12_100000_create_care_messaging_credentials_table.php @@ -0,0 +1,38 @@ +id(); + $table->string('owner_ref')->index(); + $table->foreignId('organization_id')->constrained('care_organizations')->cascadeOnDelete(); + $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(); + + $table->unique('organization_id'); + }); + } + + public function down(): void + { + Schema::dropIfExists('care_messaging_credentials'); + } +}; diff --git a/resources/views/care/integrations/edit.blade.php b/resources/views/care/integrations/edit.blade.php new file mode 100644 index 0000000..5d126ca --- /dev/null +++ b/resources/views/care/integrations/edit.blade.php @@ -0,0 +1,114 @@ + + +

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

+ + @if (! $canManage) +

You can view integrations, but only admins can change credentials.

+ @endif + +
+ + @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 + + @if ($canManage) +
+ @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 + @endif +
+ + + @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 + + @if ($canManage) +
+ @csrf +
+ + +
+
+ + +
+
+ + +
+
+ +
+
+ @if ($credential->bird_api_key_encrypted || $credential->hasValidBird()) +
+ @csrf + +
+ @endif + @endif +
+
+ +

+ ← Back to facility settings +

+
+
diff --git a/resources/views/care/patients/show.blade.php b/resources/views/care/patients/show.blade.php index 10431c5..5b1a639 100644 --- a/resources/views/care/patients/show.blade.php +++ b/resources/views/care/patients/show.blade.php @@ -137,7 +137,18 @@ @if ($canManage)

Message patient

-

SMS uses Ladill SMS credits. Email uses Ladill Bird credits.

+

Uses your connected Ladill SMS / Bird credentials. Manage integrations

+ @if (! ($messagingSmsReady ?? false) || ! ($messagingEmailReady ?? false)) +

+ @if (! ($messagingSmsReady ?? false) && ! ($messagingEmailReady ?? false)) + Connect SMS and Bird in Integrations before messaging patients. + @elseif (! ($messagingSmsReady ?? false)) + Connect Ladill SMS in Integrations to enable SMS. + @else + Connect Ladill Bird in Integrations to enable email. + @endif +

+ @endif
@@ -148,7 +159,7 @@

To: {{ $patient->phone ?: 'no phone on file' }}

- +
@@ -158,7 +169,7 @@
- +
diff --git a/resources/views/care/settings/edit.blade.php b/resources/views/care/settings/edit.blade.php index 267c910..2b4920d 100644 --- a/resources/views/care/settings/edit.blade.php +++ b/resources/views/care/settings/edit.blade.php @@ -41,6 +41,10 @@ @if ($canManage) + + Open messaging integrations → + +