feat: E.164 phone validation and storage with libphonenumber

- Add giggsey/libphonenumber-for-php, PhoneNumberNormalizer, ValidPhoneNumber rule

- Store subscribers as E.164; mutator normalizes on save; optional phone required from form block

- Migration to normalize legacy subscriber phones; Mailwizz/search/UI/tests updated

- Add run-deploy-from-local.sh and PREREGISTER_DEFAULT_PHONE_REGION in .env.example

Made-with: Cursor
This commit is contained in:
2026-04-04 14:25:52 +02:00
parent 5a67827c23
commit 17e784fee7
21 changed files with 476 additions and 18 deletions

View File

@@ -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). # 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 # 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. # Wall-clock times from the admin UI (datetime-local) are interpreted in this zone.
APP_TIMEZONE=Europe/Amsterdam APP_TIMEZONE=Europe/Amsterdam

View File

@@ -95,7 +95,7 @@ class SubscriberController extends Controller
foreach ($subscribers as $sub) { foreach ($subscribers as $sub) {
$row = [$sub->first_name, $sub->last_name, $sub->email]; $row = [$sub->first_name, $sub->last_name, $sub->email];
if ($phoneEnabled) { if ($phoneEnabled) {
$row[] = $sub->phone ?? ''; $row[] = $sub->phoneDisplay() ?? '';
} }
$row[] = $sub->created_at?->toDateTimeString() ?? ''; $row[] = $sub->created_at?->toDateTimeString() ?? '';
$row[] = $sub->synced_to_mailwizz ? 'Yes' : 'No'; $row[] = $sub->synced_to_mailwizz ? 'Yes' : 'No';

View File

@@ -5,7 +5,10 @@ declare(strict_types=1);
namespace App\Http\Requests; namespace App\Http\Requests;
use App\Models\PreregistrationPage; use App\Models\PreregistrationPage;
use App\Rules\ValidPhoneNumber;
use App\Services\PhoneNumberNormalizer;
use Illuminate\Foundation\Http\FormRequest; use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
use Illuminate\Validation\Rules\Email; use Illuminate\Validation\Rules\Email;
class SubscribePublicPageRequest extends FormRequest class SubscribePublicPageRequest extends FormRequest
@@ -28,13 +31,23 @@ class SubscribePublicPageRequest extends FormRequest
->rfcCompliant() ->rfcCompliant()
->preventSpoofing(); ->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 [ return [
'first_name' => ['required', 'string', 'max:255'], 'first_name' => ['required', 'string', 'max:255'],
'last_name' => ['required', 'string', 'max:255'], 'last_name' => ['required', 'string', 'max:255'],
'email' => ['required', 'string', 'max:255', $emailRule], 'email' => ['required', 'string', 'max:255', $emailRule],
'phone' => $page->isPhoneFieldEnabledForSubscribers() 'phone' => $phoneRules,
? ['nullable', 'string', 'regex:/^[0-9]{8,15}$/']
: ['nullable', 'string', 'max:255'],
]; ];
} }
@@ -45,7 +58,6 @@ class SubscribePublicPageRequest extends FormRequest
{ {
return [ return [
'email' => __('Please enter a valid email address.'), 'email' => __('Please enter a valid email address.'),
'phone.regex' => __('Please enter a valid phone number (815 digits).'),
]; ];
} }
@@ -73,15 +85,22 @@ class SubscribePublicPageRequest extends FormRequest
/** @var PreregistrationPage $page */ /** @var PreregistrationPage $page */
$page = $this->route('publicPage'); $page = $this->route('publicPage');
$phone = $this->input('phone');
if (! $page->isPhoneFieldEnabledForSubscribers()) { if (! $page->isPhoneFieldEnabledForSubscribers()) {
$this->merge(['phone' => null]); $this->merge(['phone' => null]);
return; return;
} }
$phone = $this->input('phone');
if ($phone === null || $phone === '') {
$this->merge(['phone' => null]);
return;
}
if (is_string($phone)) { if (is_string($phone)) {
$digits = preg_replace('/\D+/', '', $phone); $trimmed = trim($phone);
$this->merge(['phone' => $digits === '' ? null : $digits]); $this->merge(['phone' => $trimmed === '' ? null : $trimmed]);
} }
} }
} }

View File

@@ -128,7 +128,7 @@ class SyncSubscriberToMailwizz implements ShouldBeUnique, ShouldQueue
]; ];
if ($phoneEnabled && $config->field_phone !== null && $config->field_phone !== '') { if ($phoneEnabled && $config->field_phone !== null && $config->field_phone !== '') {
$phone = $subscriber->phone; $phone = $subscriber->phoneDisplay();
if ($phone !== null && $phone !== '') { if ($phone !== null && $phone !== '') {
$data[$config->field_phone] = $phone; $data[$config->field_phone] = $phone;
} }

View File

@@ -108,6 +108,19 @@ class PreregistrationPage extends Model
return (bool) $this->phone_enabled; 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 public function headlineForMeta(): string
{ {
$hero = $this->getHeroBlock(); $hero = $this->getHeroBlock();

View File

@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace App\Models; namespace App\Models;
use App\Services\PhoneNumberNormalizer;
use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
@@ -36,6 +37,52 @@ class Subscriber extends Model
return $this->belongsTo(PreregistrationPage::class); 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 public function scopeSearch(Builder $query, ?string $term): Builder
{ {
if ($term === null || $term === '') { if ($term === null || $term === '') {
@@ -47,7 +94,8 @@ class Subscriber extends Model
return $query->where(function (Builder $q) use ($like): void { return $query->where(function (Builder $q) use ($like): void {
$q->where('first_name', 'like', $like) $q->where('first_name', 'like', $like)
->orWhere('last_name', 'like', $like) ->orWhere('last_name', 'like', $like)
->orWhere('email', 'like', $like); ->orWhere('email', 'like', $like)
->orWhere('phone', 'like', $like);
}); });
} }
} }

View File

@@ -5,6 +5,7 @@ declare(strict_types=1);
namespace App\Providers; namespace App\Providers;
use App\Models\PreregistrationPage; use App\Models\PreregistrationPage;
use App\Services\PhoneNumberNormalizer;
use Illuminate\Pagination\Paginator; use Illuminate\Pagination\Paginator;
use Illuminate\Support\Facades\Route; use Illuminate\Support\Facades\Route;
use Illuminate\Support\ServiceProvider; use Illuminate\Support\ServiceProvider;
@@ -16,7 +17,11 @@ class AppServiceProvider extends ServiceProvider
*/ */
public function register(): void public function register(): void
{ {
// $this->app->singleton(PhoneNumberNormalizer::class, function (): PhoneNumberNormalizer {
return new PhoneNumberNormalizer(
(string) config('preregister.default_phone_region', 'NL')
);
});
} }
/** /**

View File

@@ -0,0 +1,33 @@
<?php
declare(strict_types=1);
namespace App\Rules;
use App\Services\PhoneNumberNormalizer;
use Closure;
use Illuminate\Contracts\Validation\ValidationRule;
final class ValidPhoneNumber implements ValidationRule
{
public function __construct(
private readonly PhoneNumberNormalizer $normalizer
) {}
public function validate(string $attribute, mixed $value, Closure $fail): void
{
if ($value === null || $value === '') {
return;
}
if (! is_string($value)) {
$fail(__('Please enter a valid phone number.'));
return;
}
if ($this->normalizer->normalizeToE164(trim($value)) === null) {
$fail(__('Please enter a valid phone number.'));
}
}
}

View File

@@ -0,0 +1,48 @@
<?php
declare(strict_types=1);
namespace App\Services;
use libphonenumber\NumberParseException;
use libphonenumber\PhoneNumberFormat;
use libphonenumber\PhoneNumberUtil;
/**
* Parses international and national phone input and normalizes to E.164 for storage (includes leading +).
*/
final class PhoneNumberNormalizer
{
public function __construct(
private readonly string $defaultRegion
) {}
/**
* Returns E.164 (e.g. +31612345678) or null if empty/invalid.
*/
public function normalizeToE164(?string $input): ?string
{
if ($input === null) {
return null;
}
$trimmed = trim($input);
if ($trimmed === '') {
return null;
}
$util = PhoneNumberUtil::getInstance();
try {
$number = $util->parse($trimmed, $this->defaultRegion);
} catch (NumberParseException) {
return null;
}
if (! $util->isValidNumber($number)) {
return null;
}
return $util->format($number, PhoneNumberFormat::E164);
}
}

View File

@@ -7,6 +7,7 @@
"license": "MIT", "license": "MIT",
"require": { "require": {
"php": "^8.3", "php": "^8.3",
"giggsey/libphonenumber-for-php": "^9.0",
"laravel/framework": "^13.0", "laravel/framework": "^13.0",
"laravel/tinker": "^3.0" "laravel/tinker": "^3.0"
}, },

136
composer.lock generated
View File

@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically" "This file is @generated automatically"
], ],
"content-hash": "0cbc4dc77a1eeff75f257f6ffae9168b", "content-hash": "505a0bb04eb0eb77eddad8d9e0ef372b",
"packages": [ "packages": [
{ {
"name": "brick/math", "name": "brick/math",
@@ -579,6 +579,140 @@
], ],
"time": "2025-12-03T09:33:47+00:00" "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", "name": "graham-campbell/result-type",
"version": "v1.1.4", "version": "v1.1.4",

View File

@@ -7,6 +7,18 @@ $defaultPerMinute = in_array($env, ['local', 'testing'], true) ? 1000 : 60;
return [ 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 | Public routes rate limit

View File

@@ -0,0 +1,57 @@
<?php
declare(strict_types=1);
use App\Models\Subscriber;
use App\Services\PhoneNumberNormalizer;
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
if (! Schema::hasTable('subscribers')) {
return;
}
/** @var PhoneNumberNormalizer $normalizer */
$normalizer = app(PhoneNumberNormalizer::class);
Subscriber::query()
->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.
}
};

View File

@@ -19,6 +19,7 @@
"You are already registered for this event.": "Je bent al geregistreerd voor dit evenement.", "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 email address.": "Voer een geldig e-mailadres in.",
"Please enter a valid phone number (815 digits).": "Voer een geldig telefoonnummer in (8 tot 15 cijfers).", "Please enter a valid phone number (815 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.", "Subscriber removed.": "Abonnee verwijderd.",
"Delete this subscriber? This cannot be undone.": "Deze abonnee verwijderen? Dit kan niet ongedaan worden gemaakt.", "Delete this subscriber? This cannot be undone.": "Deze abonnee verwijderen? Dit kan niet ongedaan worden gemaakt.",
"Remove": "Verwijderen", "Remove": "Verwijderen",

View File

@@ -390,6 +390,7 @@ document.addEventListener('alpine:init', () => {
phase: config.phase, phase: config.phase,
startAtMs: config.startAtMs, startAtMs: config.startAtMs,
phoneEnabled: config.phoneEnabled, phoneEnabled: config.phoneEnabled,
phoneRequired: config.phoneRequired === true,
subscribeUrl: config.subscribeUrl, subscribeUrl: config.subscribeUrl,
csrfToken: config.csrfToken, csrfToken: config.csrfToken,
genericError: config.genericError, genericError: config.genericError,
@@ -499,10 +500,16 @@ document.addEventListener('alpine:init', () => {
ok = false; ok = false;
} }
if (this.phoneEnabled) { if (this.phoneEnabled) {
const digits = String(this.phone).replace(/\D/g, ''); const trimmed = String(this.phone).trim();
if (digits.length > 0 && (digits.length < 8 || digits.length > 15)) { if (this.phoneRequired && trimmed === '') {
this.fieldErrors.phone = [this.invalidPhoneMsg]; this.fieldErrors.phone = [this.invalidPhoneMsg];
ok = false; 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; return ok;

View File

@@ -64,7 +64,7 @@
<td class="px-4 py-3 text-slate-900">{{ $subscriber->last_name }}</td> <td class="px-4 py-3 text-slate-900">{{ $subscriber->last_name }}</td>
<td class="px-4 py-3 text-slate-600">{{ $subscriber->email }}</td> <td class="px-4 py-3 text-slate-600">{{ $subscriber->email }}</td>
@if ($page->isPhoneFieldEnabledForSubscribers()) @if ($page->isPhoneFieldEnabledForSubscribers())
<td class="px-4 py-3 text-slate-600">{{ $subscriber->phone ?? '—' }}</td> <td class="px-4 py-3 text-slate-600">{{ $subscriber->phoneDisplay() ?? '—' }}</td>
@endif @endif
<td class="whitespace-nowrap px-4 py-3 text-slate-600">{{ $subscriber->created_at->timezone(config('app.timezone'))->format('Y-m-d H:i') }}</td> <td class="whitespace-nowrap px-4 py-3 text-slate-600">{{ $subscriber->created_at->timezone(config('app.timezone'))->format('Y-m-d H:i') }}</td>
<td class="px-4 py-3"> <td class="px-4 py-3">

View File

@@ -56,13 +56,14 @@
'phase' => $alpinePhase, 'phase' => $alpinePhase,
'startAtMs' => $page->start_date->getTimestamp() * 1000, 'startAtMs' => $page->start_date->getTimestamp() * 1000,
'phoneEnabled' => $page->isPhoneFieldEnabledForSubscribers(), 'phoneEnabled' => $page->isPhoneFieldEnabledForSubscribers(),
'phoneRequired' => $page->isPhoneFieldEnabledForSubscribers() && $page->isPhoneFieldRequiredForSubscribers(),
'subscribeUrl' => route('public.subscribe', ['publicPage' => $page]), 'subscribeUrl' => route('public.subscribe', ['publicPage' => $page]),
'csrfToken' => csrf_token(), 'csrfToken' => csrf_token(),
'genericError' => __('Something went wrong. Please try again.'), 'genericError' => __('Something went wrong. Please try again.'),
'labelDay' => __('day'), 'labelDay' => __('day'),
'labelDays' => __('days'), 'labelDays' => __('days'),
'invalidEmailMsg' => __('Please enter a valid email address.'), 'invalidEmailMsg' => __('Please enter a valid email address.'),
'invalidPhoneMsg' => __('Please enter a valid phone number (815 digits).'), 'invalidPhoneMsg' => __('Please enter a valid phone number.'),
'formButtonLabel' => $formButtonLabel, 'formButtonLabel' => $formButtonLabel,
'formButtonColor' => $formButtonColor, 'formButtonColor' => $formButtonColor,
'formButtonTextColor' => $formButtonTextColor, 'formButtonTextColor' => $formButtonTextColor,

5
run-deploy-from-local.sh Normal file
View File

@@ -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
'"

View File

@@ -170,7 +170,7 @@ class PublicPageTest extends TestCase
$response->assertJsonValidationErrors(['phone']); $response->assertJsonValidationErrors(['phone']);
} }
public function test_subscribe_normalizes_phone_to_digits(): void public function test_subscribe_stores_phone_as_e164(): void
{ {
$page = $this->makePage([ $page = $this->makePage([
'start_date' => now()->subHour(), 'start_date' => now()->subHour(),
@@ -189,7 +189,7 @@ class PublicPageTest extends TestCase
$this->assertDatabaseHas('subscribers', [ $this->assertDatabaseHas('subscribers', [
'preregistration_page_id' => $page->id, 'preregistration_page_id' => $page->id,
'email' => 'phoneuser@example.com', 'email' => 'phoneuser@example.com',
'phone' => '31612345678', 'phone' => '+31612345678',
]); ]);
} }

View File

@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace Tests\Feature; namespace Tests\Feature;
use App\Jobs\SyncSubscriberToMailwizz;
use App\Models\MailwizzConfig; use App\Models\MailwizzConfig;
use App\Models\PreregistrationPage; use App\Models\PreregistrationPage;
use App\Models\Subscriber; use App\Models\Subscriber;
@@ -91,6 +92,47 @@ class SyncSubscriberToMailwizzTest extends TestCase
$this->assertTrue($subscriber->synced_to_mailwizz); $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<string, mixed> $configOverrides * @param array<string, mixed> $configOverrides
*/ */

View File

@@ -0,0 +1,29 @@
<?php
declare(strict_types=1);
namespace Tests\Unit;
use App\Models\Subscriber;
use Tests\TestCase;
class SubscriberPhoneDisplayTest extends TestCase
{
public function test_phone_display_keeps_e164_with_plus(): void
{
$subscriber = new Subscriber(['phone' => '+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());
}
}