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:
@@ -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
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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')
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
33
app/Rules/ValidPhoneNumber.php
Normal file
33
app/Rules/ValidPhoneNumber.php
Normal 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.'));
|
||||
}
|
||||
}
|
||||
}
|
||||
48
app/Services/PhoneNumberNormalizer.php
Normal file
48
app/Services/PhoneNumberNormalizer.php
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@
|
||||
"license": "MIT",
|
||||
"require": {
|
||||
"php": "^8.3",
|
||||
"giggsey/libphonenumber-for-php": "^9.0",
|
||||
"laravel/framework": "^13.0",
|
||||
"laravel/tinker": "^3.0"
|
||||
},
|
||||
|
||||
136
composer.lock
generated
136
composer.lock
generated
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
}
|
||||
};
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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-600">{{ $subscriber->email }}</td>
|
||||
@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
|
||||
<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">
|
||||
|
||||
@@ -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,
|
||||
|
||||
5
run-deploy-from-local.sh
Normal file
5
run-deploy-from-local.sh
Normal 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
|
||||
'"
|
||||
@@ -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',
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
@@ -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<string, mixed> $configOverrides
|
||||
*/
|
||||
|
||||
29
tests/Unit/SubscriberPhoneDisplayTest.php
Normal file
29
tests/Unit/SubscriberPhoneDisplayTest.php
Normal 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());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user