From 2c2fcf9659ca44a0909cf20124f9cbfd3e90841e Mon Sep 17 00:00:00 2001 From: isaacclad Date: Sun, 12 Jul 2026 20:08:16 +0000 Subject: [PATCH] Add settings logo upload and brand queue notification emails. Let orgs manage logos in settings and render customer-facing mail with company branding. Co-authored-by: Cursor --- .../Controllers/Qms/SettingsController.php | 15 ++ app/Mail/ContactMessageMail.php | 4 + app/Services/Comms/EmailService.php | 13 +- app/Services/Qms/QueueNotificationService.php | 10 +- app/Support/OrganizationBranding.php | 23 +++ .../views/email/contact-message.blade.php | 23 ++- .../views/email/customer-branded.blade.php | 22 +++ resources/views/qms/settings/edit.blade.php | 19 ++- tests/Feature/QueueEmailBrandingTest.php | 158 ++++++++++++++++++ 9 files changed, 274 insertions(+), 13 deletions(-) create mode 100644 resources/views/email/customer-branded.blade.php create mode 100644 tests/Feature/QueueEmailBrandingTest.php diff --git a/app/Http/Controllers/Qms/SettingsController.php b/app/Http/Controllers/Qms/SettingsController.php index 95d0da1..3b7e834 100644 --- a/app/Http/Controllers/Qms/SettingsController.php +++ b/app/Http/Controllers/Qms/SettingsController.php @@ -7,6 +7,7 @@ use App\Http\Controllers\Qms\Concerns\ScopesToAccount; use App\Models\Branch; use App\Services\Qms\AuditLogger; use App\Services\Qms\QmsPermissions; +use App\Support\OrganizationBranding; use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; use Illuminate\View\View; @@ -48,6 +49,8 @@ class SettingsController extends Controller 'notifications_enabled' => ['boolean'], 'care_integration_enabled' => ['nullable', 'boolean'], 'frontdesk_integration_enabled' => ['nullable', 'boolean'], + 'logo' => ['nullable', 'image', 'mimes:jpeg,png,jpg,webp,svg', 'max:2048'], + 'remove_logo' => ['nullable', 'boolean'], ]); $settings = $organization->settings ?? []; @@ -63,9 +66,21 @@ class SettingsController extends Controller } $settings['integrations'] = $integrations; + $logoPath = $organization->logo_path; + + if ($request->boolean('remove_logo')) { + OrganizationBranding::deleteStoredLogo($organization); + $logoPath = null; + } + + if ($request->hasFile('logo')) { + $logoPath = OrganizationBranding::storeLogo($organization, $request->file('logo')); + } + $organization->update([ 'name' => $validated['name'], 'timezone' => $validated['timezone'], + 'logo_path' => $logoPath, 'settings' => $settings, ]); diff --git a/app/Mail/ContactMessageMail.php b/app/Mail/ContactMessageMail.php index 8081ad8..842ccad 100644 --- a/app/Mail/ContactMessageMail.php +++ b/app/Mail/ContactMessageMail.php @@ -17,6 +17,8 @@ class ContactMessageMail extends Mailable public string $bodyText, public ?string $fromName = null, public ?string $replyToAddress = null, + public ?string $logoUrl = null, + public ?string $companyName = null, ) {} public function envelope(): Envelope @@ -34,6 +36,8 @@ class ContactMessageMail extends Mailable with: [ 'bodyText' => $this->bodyText, 'fromName' => $this->fromName, + 'logoUrl' => $this->logoUrl, + 'companyName' => $this->companyName, ], ); } diff --git a/app/Services/Comms/EmailService.php b/app/Services/Comms/EmailService.php index bf33e0b..fc89c94 100644 --- a/app/Services/Comms/EmailService.php +++ b/app/Services/Comms/EmailService.php @@ -3,6 +3,8 @@ namespace App\Services\Comms; use App\Mail\ContactMessageMail; +use App\Models\Organization; +use App\Support\OrganizationBranding; use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Mail; @@ -13,14 +15,21 @@ use Illuminate\Support\Facades\Mail; */ class EmailService { - public function send(string $to, string $subject, string $body, ?string $fromName = null, ?string $replyTo = null): bool + public function send(string $to, string $subject, string $body, ?string $fromName = null, ?string $replyTo = null, ?Organization $organization = null): bool { if (! filter_var($to, FILTER_VALIDATE_EMAIL)) { return false; } + $logoUrl = null; + $companyName = null; + if ($organization) { + $logoUrl = OrganizationBranding::emailLogoUrl($organization); + $companyName = $organization->name; + } + try { - Mail::to($to)->send(new ContactMessageMail($subject, $body, $fromName, $replyTo)); + Mail::to($to)->send(new ContactMessageMail($subject, $body, $fromName, $replyTo, $logoUrl, $companyName)); return true; } catch (\Throwable $e) { diff --git a/app/Services/Qms/QueueNotificationService.php b/app/Services/Qms/QueueNotificationService.php index 31a6974..928793c 100644 --- a/app/Services/Qms/QueueNotificationService.php +++ b/app/Services/Qms/QueueNotificationService.php @@ -47,7 +47,8 @@ class QueueNotificationService public function appointmentReminder(\App\Models\QueueAppointment $appointment): void { $message = "Reminder: your appointment at {$appointment->scheduled_at->format('M j, H:i')} is coming up. Ref: {$appointment->reference}"; - $this->sendToContact($appointment->customer_phone, $appointment->customer_email, $message); + $organization = $appointment->organization; + $this->sendToContact($appointment->customer_phone, $appointment->customer_email, $message, $organization); } protected function notify(Ticket $ticket, string $event, string $message): void @@ -56,7 +57,8 @@ class QueueNotificationService return; } - $this->sendToContact($ticket->customer_phone, $ticket->customer_email, $message); + $organization = $ticket->organization ?? $ticket->serviceQueue?->organization; + $this->sendToContact($ticket->customer_phone, $ticket->customer_email, $message, $organization); Log::info('Queue notification sent', [ 'event' => $event, @@ -64,13 +66,13 @@ class QueueNotificationService ]); } - protected function sendToContact(?string $phone, ?string $email, string $message): void + protected function sendToContact(?string $phone, ?string $email, string $message, ?\App\Models\Organization $organization = null): void { if ($phone) { $this->sms->send($phone, $message); } if ($email) { - $this->email->send($email, 'Ladill Queue update', $message); + $this->email->send($email, 'Ladill Queue update', $message, null, null, $organization); } } diff --git a/app/Support/OrganizationBranding.php b/app/Support/OrganizationBranding.php index f6d3d8c..e25a0a4 100644 --- a/app/Support/OrganizationBranding.php +++ b/app/Support/OrganizationBranding.php @@ -54,4 +54,27 @@ class OrganizationBranding Storage::disk('public')->delete($organization->logo_path); } } + + public static function emailLogoUrl(Organization $organization): ?string + { + if (! self::hasCustomLogo($organization)) { + return null; + } + $ext = strtolower(pathinfo($organization->logo_path, PATHINFO_EXTENSION)); + if ($ext === 'svg') { + return null; + } + $version = $organization->updated_at?->getTimestamp() ?? time(); + + return Storage::disk('public')->url($organization->logo_path).'?v='.$version; + } + + public static function wrapEmailHtml(string $bodyHtml, Organization $organization): string + { + return view('email.customer-branded', [ + 'logoUrl' => self::emailLogoUrl($organization), + 'companyName' => $organization->name, + 'bodyHtml' => $bodyHtml, + ])->render(); + } } diff --git a/resources/views/email/contact-message.blade.php b/resources/views/email/contact-message.blade.php index 3cb5cc9..d4111db 100644 --- a/resources/views/email/contact-message.blade.php +++ b/resources/views/email/contact-message.blade.php @@ -4,15 +4,26 @@ - -
-
-
{{ $bodyText }}
+ +
+ @if (! empty($logoUrl) || ! empty($companyName)) +
+ @if (! empty($logoUrl)) + {{ $companyName }} + @else +
{{ $companyName }}
+ @endif +
+ @endif +
+
{{ $bodyText }}
@if (! empty($fromName)) -

— {{ $fromName }}

+

— {{ $fromName }}

@endif
-

Sent via Ladill Frontdesk

+ @if (! empty($companyName)) +

{{ $companyName }}

+ @endif
diff --git a/resources/views/email/customer-branded.blade.php b/resources/views/email/customer-branded.blade.php new file mode 100644 index 0000000..81411c5 --- /dev/null +++ b/resources/views/email/customer-branded.blade.php @@ -0,0 +1,22 @@ + + + + + + + +
+
+ @if (! empty($logoUrl)) + {{ $companyName }} + @else +
{{ $companyName }}
+ @endif +
+
+ {!! $bodyHtml !!} +
+

{{ $companyName }}

+
+ + diff --git a/resources/views/qms/settings/edit.blade.php b/resources/views/qms/settings/edit.blade.php index d0d2dcb..421a0e6 100644 --- a/resources/views/qms/settings/edit.blade.php +++ b/resources/views/qms/settings/edit.blade.php @@ -1,6 +1,6 @@ -
+ @csrf @method('PUT') @@ -33,6 +33,23 @@ settings['notifications_enabled'] ?? true) @disabled(! $canManage)> Enable SMS/email queue notifications + @if ($canManage) +
+ +

PNG, JPG, WebP, or SVG up to 2 MB. Shown in queue notification emails.

+ @if (\App\Support\OrganizationBranding::hasCustomLogo($organization)) + {{ $organization->name }} + + @endif + +
+ @endif
diff --git a/tests/Feature/QueueEmailBrandingTest.php b/tests/Feature/QueueEmailBrandingTest.php new file mode 100644 index 0000000..667ba6f --- /dev/null +++ b/tests/Feature/QueueEmailBrandingTest.php @@ -0,0 +1,158 @@ +withoutMiddleware(EnsurePlatformSession::class); + + $this->owner = User::create([ + 'public_id' => 'queue-brand-001', + 'name' => 'Owner', + 'email' => 'owner@example.com', + 'password' => bcrypt('password'), + ]); + + $this->organization = app(OrganizationResolver::class)->completeOnboarding($this->owner, [ + 'organization_name' => 'Acme Queue Co', + 'industry' => 'retail', + 'appointment_mode' => 'hybrid', + 'branch_name' => 'Main', + 'timezone' => 'UTC', + ]); + } + + public function test_org_admin_can_upload_organization_logo(): void + { + Storage::fake('public'); + + $this->actingAs($this->owner) + ->put(route('qms.settings.update'), [ + 'name' => 'Acme Queue Co', + 'timezone' => 'UTC', + 'appointment_mode' => 'hybrid', + 'industry' => 'retail', + 'notifications_enabled' => '1', + 'logo' => UploadedFile::fake()->image('company-logo.png', 400, 120), + ]) + ->assertRedirect(); + + $this->organization->refresh(); + $this->assertNotNull($this->organization->logo_path); + Storage::disk('public')->assertExists($this->organization->logo_path); + } + + public function test_queue_notification_email_contains_company_branding_without_product_logo(): void + { + Storage::fake('public'); + Mail::fake(); + + $path = UploadedFile::fake()->image('company-logo.png', 400, 120) + ->store('queue/organizations/'.$this->organization->id, 'public'); + $this->organization->update(['logo_path' => $path, 'name' => 'Acme Queue Co']); + + $branch = Branch::first(); + $queue = ServiceQueue::create([ + 'owner_ref' => $this->owner->public_id, + 'organization_id' => $this->organization->id, + 'branch_id' => $branch->id, + 'name' => 'Reception', + 'prefix' => 'A', + 'strategy' => 'fifo', + 'is_active' => true, + 'settings' => ['notifications_enabled' => true], + ]); + + $ticket = Ticket::create([ + 'owner_ref' => $this->owner->public_id, + 'organization_id' => $this->organization->id, + 'branch_id' => $branch->id, + 'service_queue_id' => $queue->id, + 'ticket_number' => 'A001', + 'status' => 'waiting', + 'customer_email' => 'customer@example.com', + 'issued_at' => now(), + ]); + + app(QueueNotificationService::class)->ticketIssued($ticket); + + $logoUrl = OrganizationBranding::emailLogoUrl($this->organization->fresh()); + + Mail::assertSent(ContactMessageMail::class, function (ContactMessageMail $mail) use ($logoUrl) { + $html = $mail->render(); + + return $mail->companyName === 'Acme Queue Co' + && $mail->logoUrl === $logoUrl + && str_contains($html, 'Acme Queue Co') + && str_contains($html, $logoUrl) + && ! str_contains($html, 'ladillqueue-logo') + && ! str_contains($html, 'Sent via Ladill'); + }); + } + + public function test_queue_notification_email_shows_company_name_when_no_logo(): void + { + Mail::fake(); + + $this->organization->update(['name' => 'Plain Name Org', 'logo_path' => null]); + + $branch = Branch::first(); + $queue = ServiceQueue::create([ + 'owner_ref' => $this->owner->public_id, + 'organization_id' => $this->organization->id, + 'branch_id' => $branch->id, + 'name' => 'Reception', + 'prefix' => 'B', + 'strategy' => 'fifo', + 'is_active' => true, + 'settings' => ['notifications_enabled' => true], + ]); + + $ticket = Ticket::create([ + 'owner_ref' => $this->owner->public_id, + 'organization_id' => $this->organization->id, + 'branch_id' => $branch->id, + 'service_queue_id' => $queue->id, + 'ticket_number' => 'B001', + 'status' => 'waiting', + 'customer_email' => 'customer@example.com', + 'issued_at' => now(), + ]); + + app(QueueNotificationService::class)->ticketIssued($ticket); + + Mail::assertSent(ContactMessageMail::class, function (ContactMessageMail $mail) { + $html = $mail->render(); + + return $mail->companyName === 'Plain Name Org' + && $mail->logoUrl === null + && str_contains($html, 'Plain Name Org') + && ! str_contains($html, 'ladillqueue-logo') + && ! str_contains($html, 'Sent via Ladill'); + }); + } +}