Skip to content
Merged
31 changes: 31 additions & 0 deletions backend/app/Helper/EmailHelper.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
<?php

namespace HiEvents\Helper;

class EmailHelper
{
private const PLUS_ALIASING_PROVIDERS = [
'gmail.com',
'googlemail.com',
'outlook.com',
'hotmail.com',
'live.com',
'protonmail.com',
'proton.me',
'fastmail.com',
'yahoo.com',
'icloud.com',
];

public static function normalize(string $email): string
{
$email = strtolower(trim($email));
[$local, $domain] = explode('@', $email, 2);

if (in_array($domain, self::PLUS_ALIASING_PROVIDERS, true)) {
$local = preg_replace('/\+.*$/', '', $local);
}

return $local . '@' . $domain;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -27,10 +27,6 @@ public function handle(CapacityChangedEvent $event): void
return;
}

if ($event->productId === null) {
return;
}

$eventDomainObject = $this->eventRepository
->loadRelation(new Relationship(EventSettingDomainObject::class))
->findById($event->eventId);
Expand All @@ -47,7 +43,7 @@ public function handle(CapacityChangedEvent $event): void
);

foreach ($quantities->productQuantities as $productQuantity) {
if ($productQuantity->product_id !== $event->productId) {
if ($event->productId !== null && $productQuantity->product_id !== $event->productId) {
continue;
}

Expand Down
2 changes: 1 addition & 1 deletion backend/app/Mail/Waitlist/WaitlistOfferExpiredMail.php
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ public function content(): Content
Url::getFrontEndUrlFromConfig(Url::EVENT_HOMEPAGE),
$this->event->getId(),
$this->event->getSlug(),
),
) . '?clear_waitlist=true',
]
);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@

namespace HiEvents\Services\Application\Handlers\EventSettings;

use HiEvents\DomainObjects\Enums\CapacityChangeDirection;
use HiEvents\DomainObjects\EventSettingDomainObject;
use HiEvents\Events\CapacityChangedEvent;
use HiEvents\Repository\Interfaces\EventSettingsRepositoryInterface;
use HiEvents\Services\Application\Handlers\EventSettings\DTO\UpdateEventSettingsDTO;
use HiEvents\Services\Infrastructure\HtmlPurifier\HtmlPurifierService;
Expand All @@ -24,7 +26,13 @@ public function __construct(
*/
public function handle(UpdateEventSettingsDTO $settings): EventSettingDomainObject
{
return $this->databaseManager->transaction(function () use ($settings) {
$existingSettings = $this->eventSettingsRepository->findFirstWhere([
'event_id' => $settings->event_id,
]);

$wasAutoProcessEnabled = $existingSettings?->getWaitlistAutoProcess();

$result = $this->databaseManager->transaction(function () use ($settings) {
$this->eventSettingsRepository->updateWhere(
attributes: [
'post_checkout_message' => $this->purifier->purify($settings->post_checkout_message),
Expand Down Expand Up @@ -104,5 +112,14 @@ public function handle(UpdateEventSettingsDTO $settings): EventSettingDomainObje
'event_id' => $settings->event_id,
]);
});

if ($settings->waitlist_auto_process && !$wasAutoProcessEnabled) {
event(new CapacityChangedEvent(
eventId: $settings->event_id,
direction: CapacityChangeDirection::INCREASED,
));
}

return $result;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
use HiEvents\DomainObjects\Status\WaitlistEntryStatus;
use HiEvents\DomainObjects\WaitlistEntryDomainObject;
use HiEvents\Exceptions\ResourceConflictException;
use HiEvents\Helper\EmailHelper;
use HiEvents\Jobs\Waitlist\SendWaitlistConfirmationEmailJob;
use HiEvents\Repository\Interfaces\WaitlistEntryRepositoryInterface;
use HiEvents\Services\Application\Handlers\Waitlist\DTO\CreateWaitlistEntryDTO;
Expand Down Expand Up @@ -42,7 +43,7 @@ public function createEntry(
return $this->waitlistEntryRepository->create([
'event_id' => $dto->event_id,
'product_price_id' => $dto->product_price_id,
'email' => strtolower(trim($dto->email)),
'email' => EmailHelper::normalize($dto->email),
'first_name' => trim($dto->first_name),
'last_name' => $dto->last_name ? trim($dto->last_name) : null,
'status' => WaitlistEntryStatus::WAITING->name,
Expand Down Expand Up @@ -73,7 +74,7 @@ private function validateWaitlistEnabled(ProductDomainObject $product): void
private function validateNoDuplicate(CreateWaitlistEntryDTO $dto): void
{
$conditions = [
'email' => strtolower(trim($dto->email)),
'email' => EmailHelper::normalize($dto->email),
'event_id' => $dto->event_id,
['status', 'in', [WaitlistEntryStatus::WAITING->name, WaitlistEntryStatus::OFFERED->name]],
'product_price_id' => $dto->product_price_id,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
<?php

namespace Tests\Unit\Services\Application\Handlers\EventSettings;

use HiEvents\DomainObjects\Enums\CapacityChangeDirection;
use HiEvents\DomainObjects\EventSettingDomainObject;
use HiEvents\Events\CapacityChangedEvent;
use HiEvents\Repository\Interfaces\EventSettingsRepositoryInterface;
use HiEvents\Services\Application\Handlers\EventSettings\DTO\UpdateEventSettingsDTO;
use HiEvents\Services\Application\Handlers\EventSettings\UpdateEventSettingsHandler;
use HiEvents\Services\Infrastructure\HtmlPurifier\HtmlPurifierService;
use Illuminate\Database\DatabaseManager;
use Illuminate\Support\Facades\Event;
use Mockery;
use Mockery\Adapter\Phpunit\MockeryPHPUnitIntegration;
use Tests\TestCase;

class UpdateEventSettingsHandlerTest extends TestCase
{
use MockeryPHPUnitIntegration;

private EventSettingsRepositoryInterface $eventSettingsRepository;
private HtmlPurifierService $purifier;
private DatabaseManager $databaseManager;
private UpdateEventSettingsHandler $handler;

protected function setUp(): void
{
parent::setUp();

$this->eventSettingsRepository = Mockery::mock(EventSettingsRepositoryInterface::class);
$this->purifier = Mockery::mock(HtmlPurifierService::class);
$this->databaseManager = Mockery::mock(DatabaseManager::class);

$this->purifier->shouldReceive('purify')->andReturnUsing(fn($v) => $v);

$this->databaseManager
->shouldReceive('transaction')
->andReturnUsing(fn($callback) => $callback());

$this->handler = new UpdateEventSettingsHandler(
eventSettingsRepository: $this->eventSettingsRepository,
purifier: $this->purifier,
databaseManager: $this->databaseManager,
);
}

public function testDispatchesCapacityEventWhenAutoProcessToggledOn(): void
{
Event::fake();

$existingSettings = new EventSettingDomainObject();
$existingSettings->setWaitlistAutoProcess(false);

$this->eventSettingsRepository
->shouldReceive('findFirstWhere')
->with(['event_id' => 1])
->twice()
->andReturn($existingSettings);

$this->eventSettingsRepository
->shouldReceive('updateWhere')
->once();

$dto = $this->createDTO(waitlist_auto_process: true);
$this->handler->handle($dto);

Event::assertDispatched(CapacityChangedEvent::class, function ($event) {
return $event->eventId === 1
&& $event->productId === null
&& $event->direction === CapacityChangeDirection::INCREASED;
});
}

public function testDoesNotDispatchEventWhenAutoProcessAlreadyEnabled(): void
{
Event::fake();

$existingSettings = new EventSettingDomainObject();
$existingSettings->setWaitlistAutoProcess(true);

$this->eventSettingsRepository
->shouldReceive('findFirstWhere')
->with(['event_id' => 1])
->twice()
->andReturn($existingSettings);

$this->eventSettingsRepository
->shouldReceive('updateWhere')
->once();

$dto = $this->createDTO(waitlist_auto_process: true);
$this->handler->handle($dto);

Event::assertNotDispatched(CapacityChangedEvent::class);
}

public function testDoesNotDispatchEventWhenAutoProcessDisabled(): void
{
Event::fake();

$existingSettings = new EventSettingDomainObject();
$existingSettings->setWaitlistAutoProcess(true);

$this->eventSettingsRepository
->shouldReceive('findFirstWhere')
->with(['event_id' => 1])
->twice()
->andReturn($existingSettings);

$this->eventSettingsRepository
->shouldReceive('updateWhere')
->once();

$dto = $this->createDTO(waitlist_auto_process: false);
$this->handler->handle($dto);

Event::assertNotDispatched(CapacityChangedEvent::class);
}

private function createDTO(?bool $waitlist_auto_process = null): UpdateEventSettingsDTO
{
return UpdateEventSettingsDTO::fromArray([
'account_id' => 1,
'event_id' => 1,
'post_checkout_message' => null,
'pre_checkout_message' => null,
'email_footer_message' => null,
'continue_button_text' => 'Continue',
'support_email' => 'test@test.com',
'homepage_background_color' => '#ffffff',
'homepage_primary_color' => '#000000',
'homepage_primary_text_color' => '#000000',
'homepage_secondary_color' => '#000000',
'homepage_secondary_text_color' => '#ffffff',
'homepage_body_background_color' => '#ffffff',
'homepage_background_type' => 'COLOR',
'require_attendee_details' => false,
'attendee_details_collection_method' => 'PER_TICKET',
'order_timeout_in_minutes' => 15,
'website_url' => null,
'maps_url' => null,
'seo_title' => null,
'seo_description' => null,
'seo_keywords' => null,
'waitlist_auto_process' => $waitlist_auto_process,
'waitlist_offer_timeout_minutes' => 60,
]);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
use HiEvents\Jobs\Waitlist\SendWaitlistConfirmationEmailJob;
use HiEvents\Repository\Interfaces\WaitlistEntryRepositoryInterface;
use HiEvents\Services\Application\Handlers\Waitlist\DTO\CreateWaitlistEntryDTO;
use HiEvents\Helper\EmailHelper;
use HiEvents\Services\Domain\Waitlist\CreateWaitlistEntryService;
use Illuminate\Database\DatabaseManager;
use Illuminate\Support\Facades\Bus;
Expand Down Expand Up @@ -203,6 +204,65 @@ public function testDispatchesSendWaitlistConfirmationEmailJob(): void
Bus::assertDispatched(SendWaitlistConfirmationEmailJob::class);
}

public function testPreventsDuplicateEntryWithPlusAlias(): void
{
$dto = new CreateWaitlistEntryDTO(
event_id: 1,
product_price_id: 10,
email: 'duplicate+alias@gmail.com',
first_name: 'Jane',
last_name: 'Doe',
);

$eventSettings = new EventSettingDomainObject();
$eventSettings->setWaitlistEnabled(true);

$product = Mockery::mock(ProductDomainObject::class);
$product->shouldReceive('getWaitlistEnabled')->andReturn(true);

$existingEntry = Mockery::mock(WaitlistEntryDomainObject::class);

$this->waitlistEntryRepository
->shouldReceive('lockForProductPrice')
->once()
->with(10);

$this->waitlistEntryRepository
->shouldReceive('findFirstWhere')
->once()
->with([
'email' => 'duplicate@gmail.com',
'event_id' => 1,
['status', 'in', [WaitlistEntryStatus::WAITING->name, WaitlistEntryStatus::OFFERED->name]],
'product_price_id' => 10,
])
->andReturn($existingEntry);

$this->expectException(ResourceConflictException::class);
$this->expectExceptionMessage('You are already on the waitlist for this product');

$this->service->createEntry($dto, $eventSettings, $product);
}

public function testNormalizeEmailStripsPlusForKnownProviders(): void
{
$this->assertEquals('user@gmail.com', EmailHelper::normalize('user+tag@gmail.com'));
$this->assertEquals('user@gmail.com', EmailHelper::normalize('User+Tag@Gmail.com'));
$this->assertEquals('user@hotmail.com', EmailHelper::normalize('user+foo@hotmail.com'));
$this->assertEquals('user@proton.me', EmailHelper::normalize('user+bar@proton.me'));
}

public function testNormalizeEmailPreservesPlusForUnknownProviders(): void
{
$this->assertEquals('user+tag@company.com', EmailHelper::normalize('user+tag@company.com'));
$this->assertEquals('user+tag@myisp.net', EmailHelper::normalize('User+Tag@MyISP.net'));
}

public function testNormalizeEmailTrimsAndLowercases(): void
{
$this->assertEquals('user@example.com', EmailHelper::normalize(' User@Example.com '));
}

public function testThrowsExceptionWhenWaitlistNotEnabledOnProduct(): void
{
$dto = new CreateWaitlistEntryDTO(
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
.wrapper {
line-height: 1;
margin-bottom: 16px;
}

.emoji {
display: inline-block;
animation: bounceIn 0.6s ease-out, subtleBounce 2s ease-in-out 0.6s infinite;
}

@keyframes bounceIn {
0% { transform: scale(0); opacity: 0; }
50% { transform: scale(1.2); }
70% { transform: scale(0.9); }
100% { transform: scale(1); opacity: 1; }
}

@keyframes subtleBounce {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-6px); }
}
13 changes: 13 additions & 0 deletions frontend/src/components/common/BouncingEmoji/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import classes from './BouncingEmoji.module.scss';

interface BouncingEmojiProps {
emoji: string;
size?: number;
}

export const BouncingEmoji = ({emoji, size = 48}: BouncingEmojiProps) => (
<div className={classes.wrapper} style={{fontSize: size}}>
{/* eslint-disable-next-line lingui/no-unlocalized-strings */}
<span className={classes.emoji}>{emoji}</span>
</div>
);
Loading
Loading