diff --git a/.env.example b/.env.example index 86053d2..6e72647 100644 --- a/.env.example +++ b/.env.example @@ -7,6 +7,9 @@ APP_URL=http://localhost # Optional: max requests/minute per IP for public /r/{slug} and subscribe (default: 1000 when APP_ENV is local|testing, else 60). # PUBLIC_REQUESTS_PER_MINUTE=120 +# Default region for parsing national phone numbers (ISO 3166-1 alpha-2). Used by libphonenumber. +# PREREGISTER_DEFAULT_PHONE_REGION=NL + # Wall-clock times from the admin UI (datetime-local) are interpreted in this zone. APP_TIMEZONE=Europe/Amsterdam diff --git a/app/Http/Controllers/Admin/SubscriberController.php b/app/Http/Controllers/Admin/SubscriberController.php index 9e87f4d..63f0e3c 100644 --- a/app/Http/Controllers/Admin/SubscriberController.php +++ b/app/Http/Controllers/Admin/SubscriberController.php @@ -95,7 +95,7 @@ class SubscriberController extends Controller foreach ($subscribers as $sub) { $row = [$sub->first_name, $sub->last_name, $sub->email]; if ($phoneEnabled) { - $row[] = $sub->phone ?? ''; + $row[] = $sub->phoneDisplay() ?? ''; } $row[] = $sub->created_at?->toDateTimeString() ?? ''; $row[] = $sub->synced_to_mailwizz ? 'Yes' : 'No'; diff --git a/app/Http/Requests/SubscribePublicPageRequest.php b/app/Http/Requests/SubscribePublicPageRequest.php index f89ed09..24158fc 100644 --- a/app/Http/Requests/SubscribePublicPageRequest.php +++ b/app/Http/Requests/SubscribePublicPageRequest.php @@ -5,7 +5,10 @@ declare(strict_types=1); namespace App\Http\Requests; use App\Models\PreregistrationPage; +use App\Rules\ValidPhoneNumber; +use App\Services\PhoneNumberNormalizer; use Illuminate\Foundation\Http\FormRequest; +use Illuminate\Validation\Rule; use Illuminate\Validation\Rules\Email; class SubscribePublicPageRequest extends FormRequest @@ -28,13 +31,23 @@ class SubscribePublicPageRequest extends FormRequest ->rfcCompliant() ->preventSpoofing(); + $phoneRules = ['nullable', 'string', 'max:255']; + + if ($page->isPhoneFieldEnabledForSubscribers()) { + $phoneRules = [ + Rule::requiredIf(fn (): bool => $page->isPhoneFieldRequiredForSubscribers()), + 'nullable', + 'string', + 'max:32', + new ValidPhoneNumber(app(PhoneNumberNormalizer::class)), + ]; + } + return [ 'first_name' => ['required', 'string', 'max:255'], 'last_name' => ['required', 'string', 'max:255'], 'email' => ['required', 'string', 'max:255', $emailRule], - 'phone' => $page->isPhoneFieldEnabledForSubscribers() - ? ['nullable', 'string', 'regex:/^[0-9]{8,15}$/'] - : ['nullable', 'string', 'max:255'], + 'phone' => $phoneRules, ]; } @@ -45,7 +58,6 @@ class SubscribePublicPageRequest extends FormRequest { return [ 'email' => __('Please enter a valid email address.'), - 'phone.regex' => __('Please enter a valid phone number (8–15 digits).'), ]; } @@ -73,15 +85,22 @@ class SubscribePublicPageRequest extends FormRequest /** @var PreregistrationPage $page */ $page = $this->route('publicPage'); - $phone = $this->input('phone'); if (! $page->isPhoneFieldEnabledForSubscribers()) { $this->merge(['phone' => null]); return; } + + $phone = $this->input('phone'); + if ($phone === null || $phone === '') { + $this->merge(['phone' => null]); + + return; + } + if (is_string($phone)) { - $digits = preg_replace('/\D+/', '', $phone); - $this->merge(['phone' => $digits === '' ? null : $digits]); + $trimmed = trim($phone); + $this->merge(['phone' => $trimmed === '' ? null : $trimmed]); } } } diff --git a/app/Jobs/SyncSubscriberToMailwizz.php b/app/Jobs/SyncSubscriberToMailwizz.php index 64d2675..4df6014 100644 --- a/app/Jobs/SyncSubscriberToMailwizz.php +++ b/app/Jobs/SyncSubscriberToMailwizz.php @@ -128,7 +128,7 @@ class SyncSubscriberToMailwizz implements ShouldBeUnique, ShouldQueue ]; if ($phoneEnabled && $config->field_phone !== null && $config->field_phone !== '') { - $phone = $subscriber->phone; + $phone = $subscriber->phoneDisplay(); if ($phone !== null && $phone !== '') { $data[$config->field_phone] = $phone; } diff --git a/app/Models/PreregistrationPage.php b/app/Models/PreregistrationPage.php index e045e9f..f03e3cd 100644 --- a/app/Models/PreregistrationPage.php +++ b/app/Models/PreregistrationPage.php @@ -108,6 +108,19 @@ class PreregistrationPage extends Model return (bool) $this->phone_enabled; } + /** + * When the form block marks the phone field as required (only applies if phone is enabled). + */ + public function isPhoneFieldRequiredForSubscribers(): bool + { + $form = $this->getBlockByType('form'); + if ($form !== null) { + return (bool) data_get($form->content, 'fields.phone.required', false); + } + + return false; + } + public function headlineForMeta(): string { $hero = $this->getHeroBlock(); diff --git a/app/Models/Subscriber.php b/app/Models/Subscriber.php index e83831f..302d7a6 100644 --- a/app/Models/Subscriber.php +++ b/app/Models/Subscriber.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace App\Models; +use App\Services\PhoneNumberNormalizer; use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; @@ -36,6 +37,52 @@ class Subscriber extends Model return $this->belongsTo(PreregistrationPage::class); } + /** + * @param string|null $value + */ + public function setPhoneAttribute(mixed $value): void + { + if ($value === null) { + $this->attributes['phone'] = null; + + return; + } + + if (! is_string($value)) { + $this->attributes['phone'] = null; + + return; + } + + $trimmed = trim($value); + if ($trimmed === '') { + $this->attributes['phone'] = null; + + return; + } + + $normalized = app(PhoneNumberNormalizer::class)->normalizeToE164($trimmed); + $this->attributes['phone'] = $normalized; + } + + /** + * Phones are stored as E.164 (e.g. +31612345678). Legacy rows may still be digits-only. + */ + public function phoneDisplay(): ?string + { + $phone = $this->phone; + if ($phone === null || $phone === '') { + return null; + } + + $p = (string) $phone; + if (str_starts_with($p, '+')) { + return $p; + } + + return preg_match('/^\d{8,15}$/', $p) === 1 ? '+'.$p : $p; + } + public function scopeSearch(Builder $query, ?string $term): Builder { if ($term === null || $term === '') { @@ -47,7 +94,8 @@ class Subscriber extends Model return $query->where(function (Builder $q) use ($like): void { $q->where('first_name', 'like', $like) ->orWhere('last_name', 'like', $like) - ->orWhere('email', 'like', $like); + ->orWhere('email', 'like', $like) + ->orWhere('phone', 'like', $like); }); } } diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 929d8ad..8c01b71 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -5,6 +5,7 @@ declare(strict_types=1); namespace App\Providers; use App\Models\PreregistrationPage; +use App\Services\PhoneNumberNormalizer; use Illuminate\Pagination\Paginator; use Illuminate\Support\Facades\Route; use Illuminate\Support\ServiceProvider; @@ -16,7 +17,11 @@ class AppServiceProvider extends ServiceProvider */ public function register(): void { - // + $this->app->singleton(PhoneNumberNormalizer::class, function (): PhoneNumberNormalizer { + return new PhoneNumberNormalizer( + (string) config('preregister.default_phone_region', 'NL') + ); + }); } /** diff --git a/app/Rules/ValidPhoneNumber.php b/app/Rules/ValidPhoneNumber.php new file mode 100644 index 0000000..b2cb625 --- /dev/null +++ b/app/Rules/ValidPhoneNumber.php @@ -0,0 +1,33 @@ +normalizer->normalizeToE164(trim($value)) === null) { + $fail(__('Please enter a valid phone number.')); + } + } +} diff --git a/app/Services/PhoneNumberNormalizer.php b/app/Services/PhoneNumberNormalizer.php new file mode 100644 index 0000000..927faaf --- /dev/null +++ b/app/Services/PhoneNumberNormalizer.php @@ -0,0 +1,48 @@ +parse($trimmed, $this->defaultRegion); + } catch (NumberParseException) { + return null; + } + + if (! $util->isValidNumber($number)) { + return null; + } + + return $util->format($number, PhoneNumberFormat::E164); + } +} diff --git a/composer.json b/composer.json index 8654a54..fd366bb 100644 --- a/composer.json +++ b/composer.json @@ -7,6 +7,7 @@ "license": "MIT", "require": { "php": "^8.3", + "giggsey/libphonenumber-for-php": "^9.0", "laravel/framework": "^13.0", "laravel/tinker": "^3.0" }, diff --git a/composer.lock b/composer.lock index c1dc24b..b88fee3 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "0cbc4dc77a1eeff75f257f6ffae9168b", + "content-hash": "505a0bb04eb0eb77eddad8d9e0ef372b", "packages": [ { "name": "brick/math", @@ -579,6 +579,140 @@ ], "time": "2025-12-03T09:33:47+00:00" }, + { + "name": "giggsey/libphonenumber-for-php", + "version": "9.0.27", + "source": { + "type": "git", + "url": "https://github.com/giggsey/libphonenumber-for-php.git", + "reference": "7973753b3efe38fb57dc949a6014a4d1cfce0ffd" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/giggsey/libphonenumber-for-php/zipball/7973753b3efe38fb57dc949a6014a4d1cfce0ffd", + "reference": "7973753b3efe38fb57dc949a6014a4d1cfce0ffd", + "shasum": "" + }, + "require": { + "giggsey/locale": "^2.7", + "php": "^8.1", + "symfony/polyfill-mbstring": "^1.31" + }, + "replace": { + "giggsey/libphonenumber-for-php-lite": "self.version" + }, + "require-dev": { + "ext-dom": "*", + "friendsofphp/php-cs-fixer": "^3.71", + "infection/infection": "^0.29|^0.31.0", + "nette/php-generator": "^4.1", + "php-coveralls/php-coveralls": "^2.7", + "phpstan/extension-installer": "^1.4.3", + "phpstan/phpstan": "^2.1.7", + "phpstan/phpstan-deprecation-rules": "^2.0.1", + "phpstan/phpstan-phpunit": "^2.0.4", + "phpstan/phpstan-strict-rules": "^2.0.3", + "phpunit/phpunit": "^10.5.45", + "symfony/console": "^6.4", + "symfony/filesystem": "^6.4", + "symfony/process": "^6.4" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "9.x-dev" + } + }, + "autoload": { + "psr-4": { + "libphonenumber\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "Apache-2.0" + ], + "authors": [ + { + "name": "Joshua Gigg", + "email": "giggsey@gmail.com", + "homepage": "https://giggsey.com/" + } + ], + "description": "A library for parsing, formatting, storing and validating international phone numbers, a PHP Port of Google's libphonenumber.", + "homepage": "https://github.com/giggsey/libphonenumber-for-php", + "keywords": [ + "geocoding", + "geolocation", + "libphonenumber", + "mobile", + "phonenumber", + "validation" + ], + "support": { + "issues": "https://github.com/giggsey/libphonenumber-for-php/issues", + "source": "https://github.com/giggsey/libphonenumber-for-php" + }, + "time": "2026-04-01T12:18:23+00:00" + }, + { + "name": "giggsey/locale", + "version": "2.9.0", + "source": { + "type": "git", + "url": "https://github.com/giggsey/Locale.git", + "reference": "fe741e99ae6ccbe8132f3d63d8ec89924e689778" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/giggsey/Locale/zipball/fe741e99ae6ccbe8132f3d63d8ec89924e689778", + "reference": "fe741e99ae6ccbe8132f3d63d8ec89924e689778", + "shasum": "" + }, + "require": { + "php": "^8.1" + }, + "require-dev": { + "ext-json": "*", + "friendsofphp/php-cs-fixer": "^3.66", + "infection/infection": "^0.29|^0.32.0", + "php-coveralls/php-coveralls": "^2.7", + "phpstan/extension-installer": "^1.4.3", + "phpstan/phpstan": "^2.1.7", + "phpstan/phpstan-deprecation-rules": "^2.0.1", + "phpstan/phpstan-phpunit": "^2.0.4", + "phpstan/phpstan-strict-rules": "^2.0.3", + "phpunit/phpunit": "^10.5.45", + "symfony/console": "^6.4", + "symfony/filesystem": "^6.4", + "symfony/finder": "^6.4", + "symfony/process": "^6.4", + "symfony/var-exporter": "^6.4" + }, + "type": "library", + "autoload": { + "psr-4": { + "Giggsey\\Locale\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Joshua Gigg", + "email": "giggsey@gmail.com", + "homepage": "https://giggsey.com/" + } + ], + "description": "Locale functions required by libphonenumber-for-php", + "support": { + "issues": "https://github.com/giggsey/Locale/issues", + "source": "https://github.com/giggsey/Locale/tree/2.9.0" + }, + "time": "2026-02-24T15:32:13+00:00" + }, { "name": "graham-campbell/result-type", "version": "v1.1.4", diff --git a/config/preregister.php b/config/preregister.php index 1feb642..0342559 100644 --- a/config/preregister.php +++ b/config/preregister.php @@ -7,6 +7,18 @@ $defaultPerMinute = in_array($env, ['local', 'testing'], true) ? 1000 : 60; return [ + /* + |-------------------------------------------------------------------------- + | Default phone region (ISO 3166-1 alpha-2) + |-------------------------------------------------------------------------- + | + | Used when parsing numbers without a country prefix (e.g. national format). + | Override with PREREGISTER_DEFAULT_PHONE_REGION in .env. + | + */ + + 'default_phone_region' => strtoupper((string) env('PREREGISTER_DEFAULT_PHONE_REGION', 'NL')), + /* |-------------------------------------------------------------------------- | Public routes rate limit diff --git a/database/migrations/2026_04_04_220000_normalize_existing_subscriber_phones_to_e164.php b/database/migrations/2026_04_04_220000_normalize_existing_subscriber_phones_to_e164.php new file mode 100644 index 0000000..4105435 --- /dev/null +++ b/database/migrations/2026_04_04_220000_normalize_existing_subscriber_phones_to_e164.php @@ -0,0 +1,57 @@ +whereNotNull('phone') + ->where('phone', '!=', '') + ->orderBy('id') + ->chunkById(100, function ($subscribers) use ($normalizer): void { + foreach ($subscribers as $subscriber) { + $p = $subscriber->phone; + if (! is_string($p) || $p === '') { + continue; + } + + if (str_starts_with($p, '+')) { + $normalized = $normalizer->normalizeToE164($p); + if ($normalized !== null && $normalized !== $p) { + $subscriber->update(['phone' => $normalized]); + } + + continue; + } + + if (preg_match('/^\d{8,15}$/', $p) !== 1) { + continue; + } + + $normalized = $normalizer->normalizeToE164('+'.$p); + if ($normalized !== null) { + $subscriber->update(['phone' => $normalized]); + } + } + }); + } + + public function down(): void + { + // Irreversible: we cannot recover original user input formatting. + } +}; diff --git a/lang/nl.json b/lang/nl.json index 560fb2f..33e95e3 100644 --- a/lang/nl.json +++ b/lang/nl.json @@ -19,6 +19,7 @@ "You are already registered for this event.": "Je bent al geregistreerd voor dit evenement.", "Please enter a valid email address.": "Voer een geldig e-mailadres in.", "Please enter a valid phone number (8–15 digits).": "Voer een geldig telefoonnummer in (8 tot 15 cijfers).", + "Please enter a valid phone number.": "Voer een geldig telefoonnummer in.", "Subscriber removed.": "Abonnee verwijderd.", "Delete this subscriber? This cannot be undone.": "Deze abonnee verwijderen? Dit kan niet ongedaan worden gemaakt.", "Remove": "Verwijderen", diff --git a/resources/js/app.js b/resources/js/app.js index 366c7ca..ed7a874 100644 --- a/resources/js/app.js +++ b/resources/js/app.js @@ -390,6 +390,7 @@ document.addEventListener('alpine:init', () => { phase: config.phase, startAtMs: config.startAtMs, phoneEnabled: config.phoneEnabled, + phoneRequired: config.phoneRequired === true, subscribeUrl: config.subscribeUrl, csrfToken: config.csrfToken, genericError: config.genericError, @@ -499,10 +500,16 @@ document.addEventListener('alpine:init', () => { ok = false; } if (this.phoneEnabled) { - const digits = String(this.phone).replace(/\D/g, ''); - if (digits.length > 0 && (digits.length < 8 || digits.length > 15)) { + const trimmed = String(this.phone).trim(); + if (this.phoneRequired && trimmed === '') { this.fieldErrors.phone = [this.invalidPhoneMsg]; ok = false; + } else if (trimmed !== '') { + const digits = trimmed.replace(/\D/g, ''); + if (digits.length < 8 || digits.length > 15) { + this.fieldErrors.phone = [this.invalidPhoneMsg]; + ok = false; + } } } return ok; diff --git a/resources/views/admin/subscribers/index.blade.php b/resources/views/admin/subscribers/index.blade.php index 15a923c..7fa9903 100644 --- a/resources/views/admin/subscribers/index.blade.php +++ b/resources/views/admin/subscribers/index.blade.php @@ -64,7 +64,7 @@ {{ $subscriber->last_name }} {{ $subscriber->email }} @if ($page->isPhoneFieldEnabledForSubscribers()) - {{ $subscriber->phone ?? '—' }} + {{ $subscriber->phoneDisplay() ?? '—' }} @endif {{ $subscriber->created_at->timezone(config('app.timezone'))->format('Y-m-d H:i') }} diff --git a/resources/views/public/page.blade.php b/resources/views/public/page.blade.php index de6c60b..5abb7d7 100644 --- a/resources/views/public/page.blade.php +++ b/resources/views/public/page.blade.php @@ -56,13 +56,14 @@ 'phase' => $alpinePhase, 'startAtMs' => $page->start_date->getTimestamp() * 1000, 'phoneEnabled' => $page->isPhoneFieldEnabledForSubscribers(), + 'phoneRequired' => $page->isPhoneFieldEnabledForSubscribers() && $page->isPhoneFieldRequiredForSubscribers(), 'subscribeUrl' => route('public.subscribe', ['publicPage' => $page]), 'csrfToken' => csrf_token(), 'genericError' => __('Something went wrong. Please try again.'), 'labelDay' => __('day'), 'labelDays' => __('days'), 'invalidEmailMsg' => __('Please enter a valid email address.'), - 'invalidPhoneMsg' => __('Please enter a valid phone number (8–15 digits).'), + 'invalidPhoneMsg' => __('Please enter a valid phone number.'), 'formButtonLabel' => $formButtonLabel, 'formButtonColor' => $formButtonColor, 'formButtonTextColor' => $formButtonTextColor, diff --git a/run-deploy-from-local.sh b/run-deploy-from-local.sh new file mode 100644 index 0000000..cde2fc9 --- /dev/null +++ b/run-deploy-from-local.sh @@ -0,0 +1,5 @@ +ssh hausdesign-vps "sudo -u hausdesign bash -c ' + export PATH=\"\$HOME/.local/share/fnm:\$PATH\" + eval \"\$(fnm env)\" + cd /home/hausdesign/preregister && ./deploy.sh +'" \ No newline at end of file diff --git a/tests/Feature/PublicPageTest.php b/tests/Feature/PublicPageTest.php index 2e7cc46..c504ad9 100644 --- a/tests/Feature/PublicPageTest.php +++ b/tests/Feature/PublicPageTest.php @@ -170,7 +170,7 @@ class PublicPageTest extends TestCase $response->assertJsonValidationErrors(['phone']); } - public function test_subscribe_normalizes_phone_to_digits(): void + public function test_subscribe_stores_phone_as_e164(): void { $page = $this->makePage([ 'start_date' => now()->subHour(), @@ -189,7 +189,7 @@ class PublicPageTest extends TestCase $this->assertDatabaseHas('subscribers', [ 'preregistration_page_id' => $page->id, 'email' => 'phoneuser@example.com', - 'phone' => '31612345678', + 'phone' => '+31612345678', ]); } diff --git a/tests/Feature/SyncSubscriberToMailwizzTest.php b/tests/Feature/SyncSubscriberToMailwizzTest.php index 7d27b04..96999a1 100644 --- a/tests/Feature/SyncSubscriberToMailwizzTest.php +++ b/tests/Feature/SyncSubscriberToMailwizzTest.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace Tests\Feature; +use App\Jobs\SyncSubscriberToMailwizz; use App\Models\MailwizzConfig; use App\Models\PreregistrationPage; use App\Models\Subscriber; @@ -91,6 +92,47 @@ class SyncSubscriberToMailwizzTest extends TestCase $this->assertTrue($subscriber->synced_to_mailwizz); } + public function test_mailwizz_sync_sends_phone_with_e164_plus_prefix(): void + { + Http::fake(function (Request $request) { + $url = $request->url(); + if (str_contains($url, 'search-by-email')) { + return Http::response(['status' => 'error']); + } + if ($request->method() === 'POST' && preg_match('#/lists/[^/]+/subscribers$#', $url) === 1) { + $body = $request->body(); + $this->assertStringContainsString('PHONE', $body); + $this->assertTrue( + str_contains($body, '+31612345678') || str_contains($body, '%2B31612345678'), + 'Expected E.164 phone with + in Mailwizz request body' + ); + + return Http::response(['status' => 'success']); + } + + return Http::response(['status' => 'error'], 500); + }); + + $page = $this->makePageWithMailwizz([ + 'field_phone' => 'PHONE', + ]); + $page->update(['phone_enabled' => true]); + + $subscriber = Subscriber::query()->create([ + 'preregistration_page_id' => $page->id, + 'first_name' => 'Test', + 'last_name' => 'User', + 'email' => 'phone-e164@example.com', + 'phone' => '+31612345678', + 'synced_to_mailwizz' => false, + ]); + + SyncSubscriberToMailwizz::dispatchSync($subscriber); + + $subscriber->refresh(); + $this->assertTrue($subscriber->synced_to_mailwizz); + } + /** * @param array $configOverrides */ diff --git a/tests/Unit/SubscriberPhoneDisplayTest.php b/tests/Unit/SubscriberPhoneDisplayTest.php new file mode 100644 index 0000000..1f97839 --- /dev/null +++ b/tests/Unit/SubscriberPhoneDisplayTest.php @@ -0,0 +1,29 @@ + '+31613210095']); + $this->assertSame('+31613210095', $subscriber->phoneDisplay()); + } + + public function test_phone_display_prefixes_plus_for_legacy_digit_only_storage(): void + { + $subscriber = new Subscriber(['phone' => '31613210095']); + $this->assertSame('+31613210095', $subscriber->phoneDisplay()); + } + + public function test_phone_display_returns_null_when_empty(): void + { + $subscriber = new Subscriber(['phone' => null]); + $this->assertNull($subscriber->phoneDisplay()); + } +}