diff --git a/api/app/Http/Requests/Api/V1/StoreOrganisationRequest.php b/api/app/Http/Requests/Api/V1/StoreOrganisationRequest.php index baa1110f..667d0caf 100644 --- a/api/app/Http/Requests/Api/V1/StoreOrganisationRequest.php +++ b/api/app/Http/Requests/Api/V1/StoreOrganisationRequest.php @@ -21,6 +21,11 @@ final class StoreOrganisationRequest extends FormRequest 'slug' => ['required', 'string', 'max:255', 'unique:organisations,slug', 'regex:/^[a-z0-9-]+$/'], 'billing_status' => ['sometimes', 'string', 'in:trial,active,suspended,cancelled'], 'settings' => ['sometimes', 'array'], + 'email_logo_url' => ['nullable', 'url', 'max:500'], + 'email_primary_color' => ['nullable', 'string', 'regex:/^#[0-9A-Fa-f]{6}$/'], + 'email_reply_to' => ['nullable', 'email'], + 'email_sender_name' => ['nullable', 'string', 'max:100'], + 'email_footer_text' => ['nullable', 'string', 'max:2000'], ]; } } diff --git a/api/app/Http/Requests/Api/V1/UpdateOrganisationRequest.php b/api/app/Http/Requests/Api/V1/UpdateOrganisationRequest.php index d97aee8c..73842ea6 100644 --- a/api/app/Http/Requests/Api/V1/UpdateOrganisationRequest.php +++ b/api/app/Http/Requests/Api/V1/UpdateOrganisationRequest.php @@ -25,6 +25,11 @@ final class UpdateOrganisationRequest extends FormRequest ], 'billing_status' => ['sometimes', 'string', 'in:active,trial,suspended'], 'settings' => ['sometimes', 'array'], + 'email_logo_url' => ['nullable', 'url', 'max:500'], + 'email_primary_color' => ['nullable', 'string', 'regex:/^#[0-9A-Fa-f]{6}$/'], + 'email_reply_to' => ['nullable', 'email'], + 'email_sender_name' => ['nullable', 'string', 'max:100'], + 'email_footer_text' => ['nullable', 'string', 'max:2000'], ]; } } diff --git a/api/app/Http/Resources/Api/V1/OrganisationResource.php b/api/app/Http/Resources/Api/V1/OrganisationResource.php index 34c96910..768ed976 100644 --- a/api/app/Http/Resources/Api/V1/OrganisationResource.php +++ b/api/app/Http/Resources/Api/V1/OrganisationResource.php @@ -17,6 +17,11 @@ final class OrganisationResource extends JsonResource 'slug' => $this->slug, 'billing_status' => $this->billing_status, 'settings' => $this->settings, + 'email_logo_url' => $this->email_logo_url, + 'email_primary_color' => $this->email_primary_color, + 'email_reply_to' => $this->email_reply_to, + 'email_sender_name' => $this->email_sender_name, + 'email_footer_text' => $this->email_footer_text, 'created_at' => $this->created_at->toIso8601String(), 'updated_at' => $this->updated_at->toIso8601String(), ]; diff --git a/api/app/Mail/CrewliMailable.php b/api/app/Mail/CrewliMailable.php new file mode 100644 index 00000000..0090ce01 --- /dev/null +++ b/api/app/Mail/CrewliMailable.php @@ -0,0 +1,41 @@ +organisation = $organisation; + } + + protected function buildBranding(): static + { + $branding = app(MailBrandingService::class)->getBranding($this->organisation); + + $this->from( + config('mail.from.address'), + $branding['sender_name'] + ); + + if ($branding['reply_to']) { + $this->replyTo($branding['reply_to']); + } + + return $this->with('branding', $branding); + } +} diff --git a/api/app/Mail/InvitationMail.php b/api/app/Mail/InvitationMail.php index 8efb19f1..18f38ded 100644 --- a/api/app/Mail/InvitationMail.php +++ b/api/app/Mail/InvitationMail.php @@ -5,21 +5,18 @@ declare(strict_types=1); namespace App\Mail; use App\Models\UserInvitation; -use Illuminate\Bus\Queueable; -use Illuminate\Contracts\Queue\ShouldQueue; -use Illuminate\Mail\Mailable; use Illuminate\Mail\Mailables\Content; use Illuminate\Mail\Mailables\Envelope; -use Illuminate\Queue\SerializesModels; -final class InvitationMail extends Mailable implements ShouldQueue +final class InvitationMail extends CrewliMailable { - use Queueable; - use SerializesModels; + public UserInvitation $invitation; - public function __construct( - public readonly UserInvitation $invitation, - ) {} + public function __construct(UserInvitation $invitation) + { + parent::__construct($invitation->organisation); + $this->invitation = $invitation; + } public function envelope(): Envelope { @@ -30,11 +27,12 @@ final class InvitationMail extends Mailable implements ShouldQueue public function content(): Content { + $this->buildBranding(); + return new Content( - markdown: 'emails.invitation', + view: 'mail.invitation', with: [ - 'acceptUrl' => config('app.frontend_app_url') . '/invitations/' . $this->invitation->token . '/accept', - 'organisationName' => $this->invitation->organisation->name, + 'acceptUrl' => config('crewli.app_url') . '/invitations/' . $this->invitation->token . '/accept', 'inviterName' => $this->invitation->invitedBy?->name ?? 'Een beheerder', 'role' => $this->invitation->role, 'expiresAt' => $this->invitation->expires_at, diff --git a/api/app/Mail/RegistrationApprovedMail.php b/api/app/Mail/RegistrationApprovedMail.php index 4a2cab90..7d569138 100644 --- a/api/app/Mail/RegistrationApprovedMail.php +++ b/api/app/Mail/RegistrationApprovedMail.php @@ -6,22 +6,20 @@ namespace App\Mail; use App\Models\Event; use App\Models\Person; -use Illuminate\Bus\Queueable; -use Illuminate\Contracts\Queue\ShouldQueue; -use Illuminate\Mail\Mailable; use Illuminate\Mail\Mailables\Content; use Illuminate\Mail\Mailables\Envelope; -use Illuminate\Queue\SerializesModels; -final class RegistrationApprovedMail extends Mailable implements ShouldQueue +final class RegistrationApprovedMail extends CrewliMailable { - use Queueable; - use SerializesModels; + public Person $person; + public Event $event; - public function __construct( - public readonly Person $person, - public readonly Event $event, - ) {} + public function __construct(Person $person, Event $event) + { + parent::__construct($event->organisation); + $this->person = $person; + $this->event = $event; + } public function envelope(): Envelope { @@ -32,12 +30,14 @@ final class RegistrationApprovedMail extends Mailable implements ShouldQueue public function content(): Content { + $this->buildBranding(); + return new Content( - markdown: 'emails.registration-approved', + view: 'mail.registration-approved', with: [ - 'personName' => $this->person->first_name, - 'eventName' => $this->event->name, - 'portalUrl' => config('app.frontend_portal_url'), + 'person' => $this->person, + 'event' => $this->event, + 'portalUrl' => config('crewli.portal_url'), ], ); } diff --git a/api/app/Mail/RegistrationConfirmationMail.php b/api/app/Mail/RegistrationConfirmationMail.php index db6a3272..6fa88b5f 100644 --- a/api/app/Mail/RegistrationConfirmationMail.php +++ b/api/app/Mail/RegistrationConfirmationMail.php @@ -6,22 +6,20 @@ namespace App\Mail; use App\Models\Event; use App\Models\Person; -use Illuminate\Bus\Queueable; -use Illuminate\Contracts\Queue\ShouldQueue; -use Illuminate\Mail\Mailable; use Illuminate\Mail\Mailables\Content; use Illuminate\Mail\Mailables\Envelope; -use Illuminate\Queue\SerializesModels; -final class RegistrationConfirmationMail extends Mailable implements ShouldQueue +final class RegistrationConfirmationMail extends CrewliMailable { - use Queueable; - use SerializesModels; + public Person $person; + public Event $event; - public function __construct( - public readonly Person $person, - public readonly Event $event, - ) {} + public function __construct(Person $person, Event $event) + { + parent::__construct($event->organisation); + $this->person = $person; + $this->event = $event; + } public function envelope(): Envelope { @@ -32,14 +30,14 @@ final class RegistrationConfirmationMail extends Mailable implements ShouldQueue public function content(): Content { + $this->buildBranding(); + return new Content( - markdown: 'emails.registration-confirmation', + view: 'mail.registration-confirmation', with: [ - 'personName' => $this->person->first_name, - 'eventName' => $this->event->name, - 'startDate' => $this->event->start_date->format('d-m-Y'), - 'endDate' => $this->event->end_date->format('d-m-Y'), - 'portalUrl' => config('app.frontend_portal_url'), + 'person' => $this->person, + 'event' => $this->event, + 'portalUrl' => config('crewli.portal_url'), ], ); } diff --git a/api/app/Mail/RegistrationRejectedMail.php b/api/app/Mail/RegistrationRejectedMail.php index d1848b01..9664fa57 100644 --- a/api/app/Mail/RegistrationRejectedMail.php +++ b/api/app/Mail/RegistrationRejectedMail.php @@ -6,23 +6,22 @@ namespace App\Mail; use App\Models\Event; use App\Models\Person; -use Illuminate\Bus\Queueable; -use Illuminate\Contracts\Queue\ShouldQueue; -use Illuminate\Mail\Mailable; use Illuminate\Mail\Mailables\Content; use Illuminate\Mail\Mailables\Envelope; -use Illuminate\Queue\SerializesModels; -final class RegistrationRejectedMail extends Mailable implements ShouldQueue +final class RegistrationRejectedMail extends CrewliMailable { - use Queueable; - use SerializesModels; + public Person $person; + public Event $event; + public ?string $reason; - public function __construct( - public readonly Person $person, - public readonly Event $event, - public readonly ?string $reason = null, - ) {} + public function __construct(Person $person, Event $event, ?string $reason = null) + { + parent::__construct($event->organisation); + $this->person = $person; + $this->event = $event; + $this->reason = $reason; + } public function envelope(): Envelope { @@ -33,11 +32,13 @@ final class RegistrationRejectedMail extends Mailable implements ShouldQueue public function content(): Content { + $this->buildBranding(); + return new Content( - markdown: 'emails.registration-rejected', + view: 'mail.registration-rejected', with: [ - 'personName' => $this->person->first_name, - 'eventName' => $this->event->name, + 'person' => $this->person, + 'event' => $this->event, 'reason' => $this->reason, ], ); diff --git a/api/app/Models/Organisation.php b/api/app/Models/Organisation.php index c20eb580..4e967232 100644 --- a/api/app/Models/Organisation.php +++ b/api/app/Models/Organisation.php @@ -22,6 +22,11 @@ final class Organisation extends Model 'slug', 'billing_status', 'settings', + 'email_logo_url', + 'email_primary_color', + 'email_reply_to', + 'email_sender_name', + 'email_footer_text', ]; protected function casts(): array diff --git a/api/app/Services/MailBrandingService.php b/api/app/Services/MailBrandingService.php new file mode 100644 index 00000000..4a5855b2 --- /dev/null +++ b/api/app/Services/MailBrandingService.php @@ -0,0 +1,25 @@ + + */ + public function getBranding(Organisation $organisation): array + { + return [ + 'organisation_name' => $organisation->name, + 'logo_url' => $organisation->email_logo_url ?? config('crewli.default_logo_url'), + 'primary_color' => $organisation->email_primary_color ?? '#6366f1', + 'reply_to' => $organisation->email_reply_to, + 'sender_name' => $organisation->email_sender_name ?? $organisation->name, + 'footer_text' => $organisation->email_footer_text, + ]; + } +} diff --git a/api/config/crewli.php b/api/config/crewli.php new file mode 100644 index 00000000..0c85b70b --- /dev/null +++ b/api/config/crewli.php @@ -0,0 +1,7 @@ + env('CREWLI_DEFAULT_LOGO_URL'), + 'portal_url' => env('CREWLI_PORTAL_URL', 'http://localhost:5175'), + 'app_url' => env('CREWLI_APP_URL', 'http://localhost:5174'), +]; diff --git a/api/database/migrations/2026_04_13_100000_add_email_branding_to_organisations_table.php b/api/database/migrations/2026_04_13_100000_add_email_branding_to_organisations_table.php new file mode 100644 index 00000000..3f0fb20d --- /dev/null +++ b/api/database/migrations/2026_04_13_100000_add_email_branding_to_organisations_table.php @@ -0,0 +1,34 @@ +string('email_logo_url', 500)->nullable()->after('settings'); + $table->string('email_primary_color', 7)->nullable()->after('email_logo_url'); + $table->string('email_reply_to')->nullable()->after('email_primary_color'); + $table->string('email_sender_name', 100)->nullable()->after('email_reply_to'); + $table->text('email_footer_text')->nullable()->after('email_sender_name'); + }); + } + + public function down(): void + { + Schema::table('organisations', function (Blueprint $table) { + $table->dropColumn([ + 'email_logo_url', + 'email_primary_color', + 'email_reply_to', + 'email_sender_name', + 'email_footer_text', + ]); + }); + } +}; diff --git a/api/resources/views/mail/invitation.blade.php b/api/resources/views/mail/invitation.blade.php new file mode 100644 index 00000000..f0f8b99b --- /dev/null +++ b/api/resources/views/mail/invitation.blade.php @@ -0,0 +1,15 @@ +@extends('mail.layouts.crewli') + +@section('title') + Je bent uitgenodigd! +@endsection + +@section('content') +

{{ $inviterName }} heeft je uitgenodigd om deel te nemen als {{ $role }}.

+ +

Deze uitnodiging verloopt op {{ $expiresAt->format('d-m-Y H:i') }} (over 7 dagen).

+@endsection + +@section('action') + Uitnodiging accepteren +@endsection diff --git a/api/resources/views/mail/layouts/crewli.blade.php b/api/resources/views/mail/layouts/crewli.blade.php new file mode 100644 index 00000000..b705d54e --- /dev/null +++ b/api/resources/views/mail/layouts/crewli.blade.php @@ -0,0 +1,70 @@ + + + + + + @yield('title') + + + + + + +
+ + {{-- Accent line --}} + + + + + {{-- Header: logo + org name --}} + + + + + {{-- Body --}} + + + + + {{-- Footer --}} + + + +
+ @if($branding['logo_url']) + {{ $branding['organisation_name'] }} + @endif +

{{ $branding['organisation_name'] }}

+
+ {{-- Title --}} +

+ @yield('title') +

+ + {{-- Content --}} +
+ @yield('content') +
+ + {{-- Action button --}} + @hasSection('action') +
+ @yield('action') +
+ @endif +
+ @if($branding['footer_text']) +

+ {{ $branding['footer_text'] }} +

+ @endif + +

+ Powered by Crewli
+ Je ontvangt deze email omdat je bent aangemeld bij {{ $branding['organisation_name'] }}. +

+
+
+ + diff --git a/api/resources/views/mail/registration-approved.blade.php b/api/resources/views/mail/registration-approved.blade.php new file mode 100644 index 00000000..e61e307b --- /dev/null +++ b/api/resources/views/mail/registration-approved.blade.php @@ -0,0 +1,17 @@ +@extends('mail.layouts.crewli') + +@section('title') + Goed nieuws! +@endsection + +@section('content') +

Beste {{ $person->first_name }},

+ +

Goed nieuws! Je bent goedgekeurd als vrijwilliger voor {{ $event->name }}.

+ +

Log in op het portaal om je shifts te bekijken en te claimen.

+@endsection + +@section('action') + Naar het portaal +@endsection diff --git a/api/resources/views/mail/registration-confirmation.blade.php b/api/resources/views/mail/registration-confirmation.blade.php new file mode 100644 index 00000000..f1475a91 --- /dev/null +++ b/api/resources/views/mail/registration-confirmation.blade.php @@ -0,0 +1,24 @@ +@extends('mail.layouts.crewli') + +@section('title') + Bedankt voor je aanmelding! +@endsection + +@section('content') +

Beste {{ $person->first_name }},

+ +

Bedankt voor je aanmelding als vrijwilliger voor {{ $event->name }}!

+ +

Je registratie wordt beoordeeld door de organisatie. Je ontvangt een e-mail zodra je aanmelding is verwerkt.

+ +

Je kunt inloggen op het vrijwilligersportaal om je status te volgen.

+ +

+ Evenement: {{ $event->name }}
+ Datum: {{ $event->start_date->format('d-m-Y') }} t/m {{ $event->end_date->format('d-m-Y') }} +

+@endsection + +@section('action') + Naar het portaal +@endsection diff --git a/api/resources/views/mail/registration-rejected.blade.php b/api/resources/views/mail/registration-rejected.blade.php new file mode 100644 index 00000000..b8ba5d47 --- /dev/null +++ b/api/resources/views/mail/registration-rejected.blade.php @@ -0,0 +1,17 @@ +@extends('mail.layouts.crewli') + +@section('title') + Update over je aanmelding +@endsection + +@section('content') +

Beste {{ $person->first_name }},

+ +

Helaas hebben we je aanmelding voor {{ $event->name }} niet kunnen goedkeuren.

+ + @if($reason) +

Reden: {{ $reason }}

+ @endif + +

Neem contact op met de organisatie als je vragen hebt.

+@endsection diff --git a/api/routes/web.php b/api/routes/web.php new file mode 100644 index 00000000..bb191bbb --- /dev/null +++ b/api/routes/web.php @@ -0,0 +1,27 @@ +environment('local', 'staging')) { + Route::get('/mail-preview/{type}', function (string $type) { + $org = Organisation::first(); + $event = Event::first(); + $person = Person::first(); + + return match ($type) { + 'registration-confirmation' => new RegistrationConfirmationMail($person, $event), + 'registration-approved' => new RegistrationApprovedMail($person, $event), + 'registration-rejected' => new RegistrationRejectedMail($person, $event, 'Helaas geen plek meer.'), + 'invitation' => new InvitationMail(UserInvitation::first()), + default => abort(404), + }; + }); +} diff --git a/api/tests/Feature/Mail/MailBrandingServiceTest.php b/api/tests/Feature/Mail/MailBrandingServiceTest.php new file mode 100644 index 00000000..cb5d1a07 --- /dev/null +++ b/api/tests/Feature/Mail/MailBrandingServiceTest.php @@ -0,0 +1,90 @@ +service = new MailBrandingService(); + } + + public function test_returns_org_branding_when_configured(): void + { + $org = Organisation::factory()->create([ + 'name' => 'Festival BV', + 'email_logo_url' => 'https://example.com/logo.png', + 'email_primary_color' => '#ff5500', + 'email_reply_to' => 'info@festival.nl', + 'email_sender_name' => 'Festival Team', + 'email_footer_text' => 'Stichting Festival, Amsterdam', + ]); + + $branding = $this->service->getBranding($org); + + $this->assertEquals('Festival BV', $branding['organisation_name']); + $this->assertEquals('https://example.com/logo.png', $branding['logo_url']); + $this->assertEquals('#ff5500', $branding['primary_color']); + $this->assertEquals('info@festival.nl', $branding['reply_to']); + $this->assertEquals('Festival Team', $branding['sender_name']); + $this->assertEquals('Stichting Festival, Amsterdam', $branding['footer_text']); + } + + public function test_falls_back_to_defaults_when_not_configured(): void + { + $org = Organisation::factory()->create([ + 'name' => 'Lege Org', + 'email_logo_url' => null, + 'email_primary_color' => null, + 'email_reply_to' => null, + 'email_sender_name' => null, + 'email_footer_text' => null, + ]); + + $branding = $this->service->getBranding($org); + + $this->assertEquals('Lege Org', $branding['organisation_name']); + $this->assertNull($branding['logo_url']); + $this->assertEquals('#6366f1', $branding['primary_color']); + $this->assertNull($branding['reply_to']); + $this->assertEquals('Lege Org', $branding['sender_name']); + $this->assertNull($branding['footer_text']); + } + + public function test_sender_name_falls_back_to_org_name(): void + { + $org = Organisation::factory()->create([ + 'name' => 'Mijn Organisatie', + 'email_sender_name' => null, + ]); + + $branding = $this->service->getBranding($org); + + $this->assertEquals('Mijn Organisatie', $branding['sender_name']); + } + + public function test_logo_url_falls_back_to_config_default(): void + { + config(['crewli.default_logo_url' => 'https://crewli.app/logo.png']); + + $org = Organisation::factory()->create([ + 'email_logo_url' => null, + ]); + + $branding = $this->service->getBranding($org); + + $this->assertEquals('https://crewli.app/logo.png', $branding['logo_url']); + } +} diff --git a/api/tests/Feature/Mail/MailLayoutTest.php b/api/tests/Feature/Mail/MailLayoutTest.php new file mode 100644 index 00000000..37abd71d --- /dev/null +++ b/api/tests/Feature/Mail/MailLayoutTest.php @@ -0,0 +1,157 @@ +organisation = Organisation::factory()->create([ + 'name' => 'Test Festival BV', + 'email_logo_url' => 'https://example.com/logo.png', + 'email_primary_color' => '#e11d48', + 'email_footer_text' => 'Stichting Test Festival, Utrecht', + ]); + + $this->event = Event::factory()->create([ + 'organisation_id' => $this->organisation->id, + 'status' => 'registration_open', + ]); + + $crowdType = CrowdType::factory()->systemType('VOLUNTEER')->create([ + 'organisation_id' => $this->organisation->id, + ]); + + $this->person = Person::factory()->create([ + 'event_id' => $this->event->id, + 'crowd_type_id' => $crowdType->id, + 'first_name' => 'Jan', + 'last_name' => 'Tester', + 'email' => 'jan@test.nl', + ]); + } + + public function test_confirmation_email_renders_with_org_logo(): void + { + $mail = new RegistrationConfirmationMail($this->person, $this->event); + $html = $mail->render(); + + $this->assertStringContainsString('https://example.com/logo.png', $html); + $this->assertStringContainsString('Test Festival BV', $html); + } + + public function test_confirmation_email_renders_with_primary_color(): void + { + $mail = new RegistrationConfirmationMail($this->person, $this->event); + $html = $mail->render(); + + $this->assertStringContainsString('#e11d48', $html); + } + + public function test_confirmation_email_renders_with_custom_footer(): void + { + $mail = new RegistrationConfirmationMail($this->person, $this->event); + $html = $mail->render(); + + $this->assertStringContainsString('Stichting Test Festival, Utrecht', $html); + } + + public function test_confirmation_email_renders_with_defaults_when_no_branding(): void + { + $plainOrg = Organisation::factory()->create([ + 'name' => 'Plain Org', + 'email_logo_url' => null, + 'email_primary_color' => null, + 'email_footer_text' => null, + ]); + + $event = Event::factory()->create([ + 'organisation_id' => $plainOrg->id, + 'status' => 'registration_open', + ]); + + $crowdType = CrowdType::factory()->systemType('VOLUNTEER')->create([ + 'organisation_id' => $plainOrg->id, + ]); + + $person = Person::factory()->create([ + 'event_id' => $event->id, + 'crowd_type_id' => $crowdType->id, + ]); + + $mail = new RegistrationConfirmationMail($person, $event); + $html = $mail->render(); + + // Default primary color + $this->assertStringContainsString('#6366f1', $html); + // Org name is shown + $this->assertStringContainsString('Plain Org', $html); + // Powered by Crewli always present + $this->assertStringContainsString('Powered by Crewli', $html); + } + + public function test_approved_email_renders_with_branding(): void + { + $mail = new RegistrationApprovedMail($this->person, $this->event); + $html = $mail->render(); + + $this->assertStringContainsString('https://example.com/logo.png', $html); + $this->assertStringContainsString('#e11d48', $html); + $this->assertStringContainsString('Goed nieuws', $html); + } + + public function test_rejected_email_renders_with_reason(): void + { + $mail = new RegistrationRejectedMail($this->person, $this->event, 'Geen plek meer.'); + $html = $mail->render(); + + $this->assertStringContainsString('Geen plek meer.', $html); + $this->assertStringContainsString('Test Festival BV', $html); + } + + public function test_rejected_email_renders_without_reason(): void + { + $mail = new RegistrationRejectedMail($this->person, $this->event); + $html = $mail->render(); + + $this->assertStringNotContainsString('Reden:', $html); + } + + public function test_all_mailables_extend_crewli_mailable(): void + { + $this->assertInstanceOf(CrewliMailable::class, new RegistrationConfirmationMail($this->person, $this->event)); + $this->assertInstanceOf(CrewliMailable::class, new RegistrationApprovedMail($this->person, $this->event)); + $this->assertInstanceOf(CrewliMailable::class, new RegistrationRejectedMail($this->person, $this->event)); + } + + public function test_powered_by_crewli_always_present(): void + { + $mail = new RegistrationConfirmationMail($this->person, $this->event); + $html = $mail->render(); + + $this->assertStringContainsString('Powered by Crewli', $html); + $this->assertStringContainsString('Je ontvangt deze email omdat je bent aangemeld bij', $html); + } +} diff --git a/api/tests/Feature/Organisation/OrganisationEmailBrandingTest.php b/api/tests/Feature/Organisation/OrganisationEmailBrandingTest.php new file mode 100644 index 00000000..f035ef79 --- /dev/null +++ b/api/tests/Feature/Organisation/OrganisationEmailBrandingTest.php @@ -0,0 +1,138 @@ +seed(RoleSeeder::class); + + $this->organisation = Organisation::factory()->create(); + $this->orgAdmin = User::factory()->create(); + $this->organisation->users()->attach($this->orgAdmin, ['role' => 'org_admin']); + } + + public function test_update_organisation_with_email_branding(): void + { + Sanctum::actingAs($this->orgAdmin); + + $response = $this->putJson("/api/v1/organisations/{$this->organisation->id}", [ + 'email_logo_url' => 'https://example.com/logo.png', + 'email_primary_color' => '#ff5500', + 'email_reply_to' => 'info@festival.nl', + 'email_sender_name' => 'Festival Team', + 'email_footer_text' => 'Stichting Festival, Amsterdam', + ]); + + $response->assertOk(); + + $this->assertDatabaseHas('organisations', [ + 'id' => $this->organisation->id, + 'email_logo_url' => 'https://example.com/logo.png', + 'email_primary_color' => '#ff5500', + 'email_reply_to' => 'info@festival.nl', + 'email_sender_name' => 'Festival Team', + 'email_footer_text' => 'Stichting Festival, Amsterdam', + ]); + } + + public function test_validate_email_primary_color_hex_format(): void + { + Sanctum::actingAs($this->orgAdmin); + + $response = $this->putJson("/api/v1/organisations/{$this->organisation->id}", [ + 'email_primary_color' => 'red', + ]); + + $response->assertUnprocessable() + ->assertJsonValidationErrors('email_primary_color'); + } + + public function test_validate_email_primary_color_accepts_valid_hex(): void + { + Sanctum::actingAs($this->orgAdmin); + + $response = $this->putJson("/api/v1/organisations/{$this->organisation->id}", [ + 'email_primary_color' => '#aaBB11', + ]); + + $response->assertOk(); + } + + public function test_validate_email_reply_to_format(): void + { + Sanctum::actingAs($this->orgAdmin); + + $response = $this->putJson("/api/v1/organisations/{$this->organisation->id}", [ + 'email_reply_to' => 'not-an-email', + ]); + + $response->assertUnprocessable() + ->assertJsonValidationErrors('email_reply_to'); + } + + public function test_validate_email_logo_url_format(): void + { + Sanctum::actingAs($this->orgAdmin); + + $response = $this->putJson("/api/v1/organisations/{$this->organisation->id}", [ + 'email_logo_url' => 'not-a-url', + ]); + + $response->assertUnprocessable() + ->assertJsonValidationErrors('email_logo_url'); + } + + public function test_organisation_resource_includes_branding_fields(): void + { + $this->organisation->update([ + 'email_logo_url' => 'https://example.com/logo.png', + 'email_primary_color' => '#ff5500', + 'email_reply_to' => 'info@festival.nl', + 'email_sender_name' => 'Festival Team', + 'email_footer_text' => 'Voetje tekst', + ]); + + Sanctum::actingAs($this->orgAdmin); + + $response = $this->getJson("/api/v1/organisations/{$this->organisation->id}"); + + $response->assertOk() + ->assertJsonPath('data.email_logo_url', 'https://example.com/logo.png') + ->assertJsonPath('data.email_primary_color', '#ff5500') + ->assertJsonPath('data.email_reply_to', 'info@festival.nl') + ->assertJsonPath('data.email_sender_name', 'Festival Team') + ->assertJsonPath('data.email_footer_text', 'Voetje tekst'); + } + + public function test_branding_fields_nullable(): void + { + Sanctum::actingAs($this->orgAdmin); + + $response = $this->putJson("/api/v1/organisations/{$this->organisation->id}", [ + 'email_logo_url' => null, + 'email_primary_color' => null, + 'email_reply_to' => null, + 'email_sender_name' => null, + 'email_footer_text' => null, + ]); + + $response->assertOk(); + } +}