added countries support expanded for ISO 3166 standard merged in new support

This commit is contained in:
Mark Gregory
2026-06-15 10:31:58 +01:00
parent 6acc6de707
commit 57d2507db9
19 changed files with 1118 additions and 120 deletions
@@ -27,15 +27,18 @@ class CountriesApiController extends Controller
->orWhere('official_name', 'ilike', "%{$search}%")
->orWhere('common_name', 'ilike', "%{$search}%")
->orWhere('display_name', 'ilike', "%{$search}%")
->orWhere('native_name', 'ilike', "%{$search}%")
->orWhere('alpha2', 'ilike', "%{$search}%")
->orWhere('alpha3', 'ilike', "%{$search}%")
->orWhere('numeric', 'ilike', "%{$search}%")
->orWhere('region', 'ilike', "%{$search}%")
->orWhere('subregion', 'ilike', "%{$search}%");
->orWhere('subregion', 'ilike', "%{$search}%")
->orWhere('currency_code', 'ilike', "%{$search}%")
->orWhere('calling_code', 'ilike', "%{$search}%");
});
})
->ordered()
->get(['alpha2', 'alpha3', 'numeric', 'name', 'official_name', 'common_name', 'display_name', 'flag_emoji', 'is_active', 'iso_status', 'independent', 'region', 'subregion']);
->get();
return response()->json(['data' => $countries]);
}
@@ -45,8 +48,7 @@ class CountriesApiController extends Controller
*/
public function active(): JsonResponse
{
$countries = Country::query()->active()->ordered()
->get(['alpha2', 'alpha3', 'numeric', 'name', 'display_name', 'common_name', 'flag_emoji', 'region', 'subregion']);
$countries = Country::query()->active()->ordered()->get();
return response()->json(['data' => $countries]);
}
@@ -85,7 +87,7 @@ class CountriesApiController extends Controller
->where('country_id', $country->id)
->when($activeOnly, fn ($query) => $query->where('is_active', true))
->ordered()
->get(['country_alpha2', 'code', 'subdivision_code', 'name', 'local_name', 'type', 'is_active', 'iso_status']);
->get();
return response()->json(['data' => $subdivisions]);
}
@@ -6,15 +6,21 @@ use Illuminate\Contracts\View\View;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Routing\Controller;
use Modules\Countries\Http\Requests\StoreCountryRequest;
use Modules\Countries\Http\Requests\UpdateCountryRequest;
use Modules\Countries\Models\Country;
use Modules\Countries\Support\CountryUsageReport;
class CountriesController extends Controller
{
/**
* Display ISO 3166 country records.
*/
public function index(Request $request): View
{
$search = trim((string) $request->query('search', ''));
$status = (string) $request->query('status', 'all');
$region = (string) $request->query('region', 'all');
$countries = Country::query()
->when($search !== '', function ($query) use ($search): void {
@@ -23,13 +29,17 @@ class CountriesController extends Controller
->orWhere('official_name', 'ilike', "%{$search}%")
->orWhere('common_name', 'ilike', "%{$search}%")
->orWhere('display_name', 'ilike', "%{$search}%")
->orWhere('native_name', 'ilike', "%{$search}%")
->orWhere('alpha2', 'ilike', "%{$search}%")
->orWhere('alpha3', 'ilike', "%{$search}%")
->orWhere('numeric', 'ilike', "%{$search}%");
->orWhere('numeric', 'ilike', "%{$search}%")
->orWhere('currency_code', 'ilike', "%{$search}%")
->orWhere('calling_code', 'ilike', "%{$search}%");
});
})
->when($status === 'active', fn ($query) => $query->where('is_active', true))
->when($status === 'inactive', fn ($query) => $query->where('is_active', false))
->when($region !== 'all' && $region !== '', fn ($query) => $query->where('region', $region))
->ordered()
->paginate(50)
->withQueryString();
@@ -37,32 +47,97 @@ class CountriesController extends Controller
$activeCount = Country::query()->where('is_active', true)->count();
$inactiveCount = Country::query()->where('is_active', false)->count();
$totalCount = Country::query()->count();
$regions = Country::query()->whereNotNull('region')->distinct()->orderBy('region')->pluck('region');
return view('countries::index', compact('countries', 'activeCount', 'inactiveCount', 'totalCount', 'search', 'status'));
return view('countries::index', compact('countries', 'activeCount', 'inactiveCount', 'totalCount', 'search', 'status', 'region', 'regions'));
}
public function show(Country $country): View
/**
* Show the country creation form.
*/
public function create(): View
{
return view('countries::show', compact('country'));
return view('countries::create', ['country' => new Country()]);
}
/**
* Store a new ISO country record.
*/
public function store(StoreCountryRequest $request): RedirectResponse
{
$country = Country::query()->create($this->payload($request));
return redirect()->route('countries.show', $country)->with('success', 'Country created.');
}
/**
* Show a country record.
*/
public function show(Country $country, CountryUsageReport $usageReport): View
{
$country->load(['subdivisions' => fn ($query) => $query->ordered()->limit(250)]);
$usageRows = $usageReport->forCountry($country);
return view('countries::show', compact('country', 'usageRows'));
}
/**
* Show the edit form.
*/
public function edit(Country $country): View
{
return view('countries::edit', compact('country'));
}
/**
* Update all editable ISO/support fields.
*/
public function update(UpdateCountryRequest $request, Country $country): RedirectResponse
{
$country->update([
'display_name' => $request->validated('display_name'),
'is_active' => $request->boolean('is_active'),
'sort_order' => (int) ($request->validated('sort_order') ?? 0),
'iso_status' => $request->validated('iso_status'),
'independent' => $request->boolean('independent'),
'region' => $request->validated('region'),
'subregion' => $request->validated('subregion'),
]);
$country->update($this->payload($request));
return redirect()->route('countries.show', $country)->with('success', 'Country settings updated.');
return redirect()->route('countries.show', $country)->with('success', 'Country updated.');
}
/**
* Soft-delete a custom/retired country record.
*/
public function destroy(Country $country): RedirectResponse
{
$country->delete();
return redirect()->route('countries.index')->with('success', 'Country archived.');
}
/**
* Build the database payload from validated request data.
*
* @param StoreCountryRequest|UpdateCountryRequest $request
* @return array<string, mixed>
*/
private function payload(StoreCountryRequest|UpdateCountryRequest $request): array
{
$data = $request->validated();
foreach (['alpha2', 'alpha3', 'currency_code', 'ioc_code', 'fifa_code'] as $key) {
if (isset($data[$key]) && is_string($data[$key])) {
$data[$key] = strtoupper(trim($data[$key]));
}
}
if (isset($data['numeric']) && is_string($data['numeric'])) {
$data['numeric'] = str_pad(trim($data['numeric']), 3, '0', STR_PAD_LEFT);
}
foreach (['languages', 'timezones', 'metadata'] as $jsonField) {
$value = $data[$jsonField] ?? null;
$data[$jsonField] = blank($value) ? null : json_decode((string) $value, true, 512, JSON_THROW_ON_ERROR);
}
foreach (['is_active', 'independent', 'un_member'] as $booleanField) {
$data[$booleanField] = $request->boolean($booleanField);
}
return $data;
}
}
@@ -0,0 +1,22 @@
<?php
namespace Modules\Countries\Http\Controllers;
use Illuminate\Http\RedirectResponse;
use Illuminate\Routing\Controller;
use Illuminate\Support\Facades\Artisan;
class CountrySyncController extends Controller
{
/**
* Synchronise bundled ISO 3166 reference data.
*/
public function __invoke(): RedirectResponse
{
Artisan::call('countries:sync-iso', [
'--subdivisions' => true,
]);
return redirect()->route('countries.index')->with('success', 'ISO 3166 country reference data synchronised.');
}
}
@@ -0,0 +1,82 @@
<?php
namespace Modules\Countries\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
use Modules\Countries\Support\Iso3166;
class StoreCountryRequest extends FormRequest
{
/**
* Authorize country reference-data administration.
*/
public function authorize(): bool
{
return (bool) $this->user()?->isMpbaPlatformAdmin();
}
/**
* Normalise ISO code inputs before validation.
*/
protected function prepareForValidation(): void
{
$this->merge([
'alpha2' => strtoupper(trim((string) $this->input('alpha2'))),
'alpha3' => strtoupper(trim((string) $this->input('alpha3'))),
'numeric' => str_pad(trim((string) $this->input('numeric')), 3, '0', STR_PAD_LEFT),
'currency_code' => $this->filled('currency_code') ? strtoupper(trim((string) $this->input('currency_code'))) : null,
'ioc_code' => $this->filled('ioc_code') ? strtoupper(trim((string) $this->input('ioc_code'))) : null,
'fifa_code' => $this->filled('fifa_code') ? strtoupper(trim((string) $this->input('fifa_code'))) : null,
]);
}
/**
* @return array<string, mixed>
*/
public function rules(): array
{
return [
'alpha2' => ['required', 'string', 'size:2', 'uppercase', 'unique:countries,alpha2'],
'alpha3' => ['required', 'string', 'size:3', 'uppercase', 'unique:countries,alpha3'],
'numeric' => ['required', 'string', 'size:3', 'unique:countries,numeric'],
'name' => ['required', 'string', 'max:255'],
'official_name' => ['nullable', 'string', 'max:255'],
'common_name' => ['nullable', 'string', 'max:255'],
'display_name' => ['nullable', 'string', 'max:255'],
'native_name' => ['nullable', 'string', 'max:255'],
'capital' => ['nullable', 'string', 'max:255'],
'flag_emoji' => ['nullable', 'string', 'max:16'],
'calling_code' => ['nullable', 'string', 'max:16'],
'tld' => ['nullable', 'string', 'max:16'],
'currency_code' => ['nullable', 'string', 'size:3', 'uppercase'],
'currency_name' => ['nullable', 'string', 'max:255'],
'currency_symbol' => ['nullable', 'string', 'max:16'],
'languages' => ['nullable', 'json'],
'timezones' => ['nullable', 'json'],
'latitude' => ['nullable', 'numeric', 'between:-90,90'],
'longitude' => ['nullable', 'numeric', 'between:-180,180'],
'flag_svg_url' => ['nullable', 'url', 'max:255'],
'flag_png_url' => ['nullable', 'url', 'max:255'],
'ioc_code' => ['nullable', 'string', 'max:8'],
'fifa_code' => ['nullable', 'string', 'max:8'],
'postal_code_format' => ['nullable', 'string', 'max:255'],
'address_format' => ['nullable', 'string', 'max:5000'],
'is_active' => ['required', 'boolean'],
'iso_status' => ['required', 'string', Rule::in(Iso3166::statuses())],
'independent' => ['nullable', 'boolean'],
'sovereignty_status' => ['nullable', 'string', 'max:64'],
'un_member' => ['nullable', 'boolean'],
'continent' => ['nullable', 'string', 'max:64'],
'region' => ['nullable', 'string', 'max:100'],
'subregion' => ['nullable', 'string', 'max:100'],
'intermediate_region' => ['nullable', 'string', 'max:255'],
'sort_order' => ['nullable', 'integer', 'min:0', 'max:999999'],
'source' => ['nullable', 'string', 'max:255'],
'source_version' => ['nullable', 'string', 'max:255'],
'last_synced_at' => ['nullable', 'date'],
'metadata' => ['nullable', 'json'],
];
}
}
@@ -16,19 +16,69 @@ class UpdateCountryRequest extends FormRequest
return (bool) $this->user()?->isMpbaPlatformAdmin();
}
/**
* Normalise ISO code inputs before validation.
*/
protected function prepareForValidation(): void
{
$this->merge([
'alpha2' => strtoupper(trim((string) $this->input('alpha2'))),
'alpha3' => strtoupper(trim((string) $this->input('alpha3'))),
'numeric' => str_pad(trim((string) $this->input('numeric')), 3, '0', STR_PAD_LEFT),
'currency_code' => $this->filled('currency_code') ? strtoupper(trim((string) $this->input('currency_code'))) : null,
'ioc_code' => $this->filled('ioc_code') ? strtoupper(trim((string) $this->input('ioc_code'))) : null,
'fifa_code' => $this->filled('fifa_code') ? strtoupper(trim((string) $this->input('fifa_code'))) : null,
]);
}
/**
* @return array<string, mixed>
*/
public function rules(): array
{
$countryId = $this->route('country')?->id;
return [
'alpha2' => ['required', 'string', 'size:2', 'uppercase', Rule::unique('countries', 'alpha2')->ignore($countryId)],
'alpha3' => ['required', 'string', 'size:3', 'uppercase', Rule::unique('countries', 'alpha3')->ignore($countryId)],
'numeric' => ['required', 'string', 'size:3', Rule::unique('countries', 'numeric')->ignore($countryId)],
'name' => ['required', 'string', 'max:255'],
'official_name' => ['nullable', 'string', 'max:255'],
'common_name' => ['nullable', 'string', 'max:255'],
'display_name' => ['nullable', 'string', 'max:255'],
'is_active' => ['nullable', 'boolean'],
'sort_order' => ['nullable', 'integer', 'min:0', 'max:999999'],
'native_name' => ['nullable', 'string', 'max:255'],
'capital' => ['nullable', 'string', 'max:255'],
'flag_emoji' => ['nullable', 'string', 'max:16'],
'calling_code' => ['nullable', 'string', 'max:16'],
'tld' => ['nullable', 'string', 'max:16'],
'currency_code' => ['nullable', 'string', 'size:3', 'uppercase'],
'currency_name' => ['nullable', 'string', 'max:255'],
'currency_symbol' => ['nullable', 'string', 'max:16'],
'languages' => ['nullable', 'json'],
'timezones' => ['nullable', 'json'],
'latitude' => ['nullable', 'numeric', 'between:-90,90'],
'longitude' => ['nullable', 'numeric', 'between:-180,180'],
'flag_svg_url' => ['nullable', 'url', 'max:255'],
'flag_png_url' => ['nullable', 'url', 'max:255'],
'ioc_code' => ['nullable', 'string', 'max:8'],
'fifa_code' => ['nullable', 'string', 'max:8'],
'postal_code_format' => ['nullable', 'string', 'max:255'],
'address_format' => ['nullable', 'string', 'max:5000'],
'is_active' => ['required', 'boolean'],
'iso_status' => ['required', 'string', Rule::in(Iso3166::statuses())],
'independent' => ['nullable', 'boolean'],
'sovereignty_status' => ['nullable', 'string', 'max:64'],
'un_member' => ['nullable', 'boolean'],
'continent' => ['nullable', 'string', 'max:64'],
'region' => ['nullable', 'string', 'max:100'],
'subregion' => ['nullable', 'string', 'max:100'],
'intermediate_region' => ['nullable', 'string', 'max:255'],
'sort_order' => ['nullable', 'integer', 'min:0', 'max:999999'],
'source' => ['nullable', 'string', 'max:255'],
'source_version' => ['nullable', 'string', 'max:255'],
'last_synced_at' => ['nullable', 'date'],
'metadata' => ['nullable', 'json'],
];
}
}