update
This commit is contained in:
100
app/Services/Countries/CountryCatalogService.php
Normal file
100
app/Services/Countries/CountryCatalogService.php
Normal file
@@ -0,0 +1,100 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Countries;
|
||||
|
||||
use App\Models\Country;
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
final class CountryCatalogService
|
||||
{
|
||||
public const ACTIVE_ALL_CACHE_KEY = 'countries.active.all';
|
||||
public const PROFILE_SELECT_CACHE_KEY = 'countries.profile.select';
|
||||
|
||||
/**
|
||||
* @return Collection<int, Country>
|
||||
*/
|
||||
public function activeCountries(): Collection
|
||||
{
|
||||
if (! Schema::hasTable('countries')) {
|
||||
return collect();
|
||||
}
|
||||
|
||||
/** @var Collection<int, Country> $countries */
|
||||
$countries = Cache::remember(
|
||||
self::ACTIVE_ALL_CACHE_KEY,
|
||||
max(60, (int) config('skinbase-countries.cache_ttl', 86400)),
|
||||
fn (): Collection => Country::query()->active()->ordered()->get(),
|
||||
);
|
||||
|
||||
return $countries;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
public function profileSelectOptions(): array
|
||||
{
|
||||
return Cache::remember(
|
||||
self::PROFILE_SELECT_CACHE_KEY,
|
||||
max(60, (int) config('skinbase-countries.cache_ttl', 86400)),
|
||||
fn (): array => $this->activeCountries()
|
||||
->map(fn (Country $country): array => [
|
||||
'id' => $country->id,
|
||||
'iso2' => $country->iso2,
|
||||
'name' => $country->name_common,
|
||||
'flag_emoji' => $country->flag_emoji,
|
||||
'flag_css_class' => $country->flag_css_class,
|
||||
'is_featured' => $country->is_featured,
|
||||
'flag_path' => $country->local_flag_path,
|
||||
])
|
||||
->values()
|
||||
->all(),
|
||||
);
|
||||
}
|
||||
|
||||
public function findById(?int $countryId): ?Country
|
||||
{
|
||||
if ($countryId === null || $countryId <= 0 || ! Schema::hasTable('countries')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return Country::query()->find($countryId);
|
||||
}
|
||||
|
||||
public function findByIso2(?string $iso2): ?Country
|
||||
{
|
||||
$normalized = strtoupper(trim((string) $iso2));
|
||||
|
||||
if ($normalized === '' || ! preg_match('/^[A-Z]{2}$/', $normalized) || ! Schema::hasTable('countries')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return Country::query()->where('iso2', $normalized)->first();
|
||||
}
|
||||
|
||||
public function resolveUserCountry(User $user): ?Country
|
||||
{
|
||||
if ($user->relationLoaded('country') && $user->country instanceof Country) {
|
||||
return $user->country;
|
||||
}
|
||||
|
||||
if (! empty($user->country_id)) {
|
||||
return $this->findById((int) $user->country_id);
|
||||
}
|
||||
|
||||
$countryCode = strtoupper((string) ($user->profile?->country_code ?? ''));
|
||||
|
||||
return $countryCode !== '' ? $this->findByIso2($countryCode) : null;
|
||||
}
|
||||
|
||||
public function flushCache(): void
|
||||
{
|
||||
Cache::forget(self::ACTIVE_ALL_CACHE_KEY);
|
||||
Cache::forget(self::PROFILE_SELECT_CACHE_KEY);
|
||||
}
|
||||
}
|
||||
115
app/Services/Countries/CountryRemoteProvider.php
Normal file
115
app/Services/Countries/CountryRemoteProvider.php
Normal file
@@ -0,0 +1,115 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Countries;
|
||||
|
||||
use Illuminate\Http\Client\Factory as HttpFactory;
|
||||
use RuntimeException;
|
||||
|
||||
final class CountryRemoteProvider implements CountryRemoteProviderInterface
|
||||
{
|
||||
public function __construct(
|
||||
private readonly HttpFactory $http,
|
||||
) {
|
||||
}
|
||||
|
||||
public function fetchAll(): array
|
||||
{
|
||||
$endpoint = trim((string) config('skinbase-countries.endpoint', ''));
|
||||
|
||||
if ($endpoint === '') {
|
||||
throw new RuntimeException('Country sync endpoint is not configured.');
|
||||
}
|
||||
|
||||
$response = $this->http->acceptJson()
|
||||
->connectTimeout(max(1, (int) config('skinbase-countries.connect_timeout', 5)))
|
||||
->timeout(max(1, (int) config('skinbase-countries.timeout', 10)))
|
||||
->retry(
|
||||
max(0, (int) config('skinbase-countries.retry_times', 2)),
|
||||
max(0, (int) config('skinbase-countries.retry_sleep_ms', 250)),
|
||||
throw: false,
|
||||
)
|
||||
->get($endpoint);
|
||||
|
||||
if (! $response->successful()) {
|
||||
throw new RuntimeException(sprintf('Country sync request failed with status %d.', $response->status()));
|
||||
}
|
||||
|
||||
$payload = $response->json();
|
||||
|
||||
if (! is_array($payload)) {
|
||||
throw new RuntimeException('Country sync response was not a JSON array.');
|
||||
}
|
||||
|
||||
return $this->normalizePayload($payload);
|
||||
}
|
||||
|
||||
public function normalizePayload(array $payload): array
|
||||
{
|
||||
$normalized = [];
|
||||
|
||||
foreach ($payload as $record) {
|
||||
if (! is_array($record)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$country = $this->normalizeRecord($record);
|
||||
|
||||
if ($country !== null) {
|
||||
$normalized[] = $country;
|
||||
}
|
||||
}
|
||||
|
||||
return $normalized;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $record
|
||||
* @return array<string, mixed>|null
|
||||
*/
|
||||
private function normalizeRecord(array $record): ?array
|
||||
{
|
||||
$iso2 = strtoupper(trim((string) ($record['cca2'] ?? $record['iso2'] ?? '')));
|
||||
|
||||
if (! preg_match('/^[A-Z]{2}$/', $iso2)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$iso3 = strtoupper(trim((string) ($record['cca3'] ?? $record['iso3'] ?? '')));
|
||||
$iso3 = preg_match('/^[A-Z]{3}$/', $iso3) ? $iso3 : null;
|
||||
|
||||
$numericCode = trim((string) ($record['ccn3'] ?? $record['numeric_code'] ?? ''));
|
||||
$numericCode = preg_match('/^\d{1,3}$/', $numericCode)
|
||||
? str_pad($numericCode, 3, '0', STR_PAD_LEFT)
|
||||
: null;
|
||||
|
||||
$name = $record['name'] ?? [];
|
||||
$nameCommon = trim((string) ($name['common'] ?? $record['name_common'] ?? ''));
|
||||
|
||||
if ($nameCommon === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$nameOfficial = trim((string) ($name['official'] ?? $record['name_official'] ?? ''));
|
||||
$flags = $record['flags'] ?? [];
|
||||
$flagSvgUrl = trim((string) ($flags['svg'] ?? $record['flag_svg_url'] ?? ''));
|
||||
$flagPngUrl = trim((string) ($flags['png'] ?? $record['flag_png_url'] ?? ''));
|
||||
$flagEmoji = trim((string) ($record['flag'] ?? $record['flag_emoji'] ?? ''));
|
||||
$region = trim((string) ($record['region'] ?? ''));
|
||||
$subregion = trim((string) ($record['subregion'] ?? ''));
|
||||
|
||||
return [
|
||||
'iso2' => $iso2,
|
||||
'iso3' => $iso3,
|
||||
'numeric_code' => $numericCode,
|
||||
'name_common' => $nameCommon,
|
||||
'name_official' => $nameOfficial !== '' ? $nameOfficial : null,
|
||||
'region' => $region !== '' ? $region : null,
|
||||
'subregion' => $subregion !== '' ? $subregion : null,
|
||||
'flag_svg_url' => $flagSvgUrl !== '' ? $flagSvgUrl : null,
|
||||
'flag_png_url' => $flagPngUrl !== '' ? $flagPngUrl : null,
|
||||
'flag_emoji' => $flagEmoji !== '' ? $flagEmoji : null,
|
||||
];
|
||||
}
|
||||
}
|
||||
23
app/Services/Countries/CountryRemoteProviderInterface.php
Normal file
23
app/Services/Countries/CountryRemoteProviderInterface.php
Normal file
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Countries;
|
||||
|
||||
interface CountryRemoteProviderInterface
|
||||
{
|
||||
/**
|
||||
* Fetch and normalize all remote countries.
|
||||
*
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
public function fetchAll(): array;
|
||||
|
||||
/**
|
||||
* Normalize a raw payload into syncable country records.
|
||||
*
|
||||
* @param array<int, mixed> $payload
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
public function normalizePayload(array $payload): array;
|
||||
}
|
||||
193
app/Services/Countries/CountrySyncService.php
Normal file
193
app/Services/Countries/CountrySyncService.php
Normal file
@@ -0,0 +1,193 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Countries;
|
||||
|
||||
use App\Models\Country;
|
||||
use Illuminate\Support\Arr;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use JsonException;
|
||||
use RuntimeException;
|
||||
use Throwable;
|
||||
|
||||
final class CountrySyncService
|
||||
{
|
||||
public function __construct(
|
||||
private readonly CountryRemoteProviderInterface $remoteProvider,
|
||||
private readonly CountryCatalogService $catalog,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, int|string|null>
|
||||
*/
|
||||
public function sync(bool $allowFallback = true, ?bool $deactivateMissing = null): array
|
||||
{
|
||||
if (! (bool) config('skinbase-countries.enabled', true)) {
|
||||
throw new RuntimeException('Countries sync is disabled by configuration.');
|
||||
}
|
||||
|
||||
$summary = [
|
||||
'source' => null,
|
||||
'total_fetched' => 0,
|
||||
'inserted' => 0,
|
||||
'updated' => 0,
|
||||
'skipped' => 0,
|
||||
'invalid' => 0,
|
||||
'deactivated' => 0,
|
||||
'backfilled_users' => 0,
|
||||
];
|
||||
|
||||
try {
|
||||
$records = $this->remoteProvider->fetchAll();
|
||||
$summary['source'] = (string) config('skinbase-countries.remote_source', 'remote');
|
||||
} catch (Throwable $exception) {
|
||||
if (! $allowFallback || ! (bool) config('skinbase-countries.fallback_seed_enabled', true)) {
|
||||
throw new RuntimeException('Country sync failed: '.$exception->getMessage(), previous: $exception);
|
||||
}
|
||||
|
||||
$records = $this->loadFallbackRecords();
|
||||
$summary['source'] = 'fallback';
|
||||
}
|
||||
|
||||
if ($records === []) {
|
||||
throw new RuntimeException('Country sync did not yield any valid country records.');
|
||||
}
|
||||
|
||||
$summary['total_fetched'] = count($records);
|
||||
$seenIso2 = [];
|
||||
$featured = array_values(array_filter(array_map(
|
||||
static fn (mixed $iso2): string => strtoupper(trim((string) $iso2)),
|
||||
(array) config('skinbase-countries.featured_countries', []),
|
||||
)));
|
||||
$featuredOrder = array_flip($featured);
|
||||
|
||||
DB::transaction(function () use (&$summary, $records, &$seenIso2, $featuredOrder, $deactivateMissing): void {
|
||||
foreach ($records as $record) {
|
||||
$iso2 = strtoupper((string) ($record['iso2'] ?? ''));
|
||||
|
||||
if (! preg_match('/^[A-Z]{2}$/', $iso2)) {
|
||||
$summary['invalid']++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (isset($seenIso2[$iso2])) {
|
||||
$summary['skipped']++;
|
||||
continue;
|
||||
}
|
||||
|
||||
$seenIso2[$iso2] = true;
|
||||
|
||||
$country = Country::query()->firstOrNew(['iso2' => $iso2]);
|
||||
$exists = $country->exists;
|
||||
$featuredIndex = $featuredOrder[$iso2] ?? null;
|
||||
|
||||
$country->fill([
|
||||
'iso' => $iso2,
|
||||
'iso3' => $record['iso3'] ?? null,
|
||||
'numeric_code' => $record['numeric_code'] ?? null,
|
||||
'name' => $record['name_common'],
|
||||
'native' => $record['name_official'] ?? null,
|
||||
'continent' => $this->continentCode($record['region'] ?? null),
|
||||
'name_common' => $record['name_common'],
|
||||
'name_official' => $record['name_official'] ?? null,
|
||||
'region' => $record['region'] ?? null,
|
||||
'subregion' => $record['subregion'] ?? null,
|
||||
'flag_svg_url' => $record['flag_svg_url'] ?? null,
|
||||
'flag_png_url' => $record['flag_png_url'] ?? null,
|
||||
'flag_emoji' => $record['flag_emoji'] ?? null,
|
||||
'active' => true,
|
||||
'is_featured' => $featuredIndex !== null,
|
||||
'sort_order' => $featuredIndex !== null ? $featuredIndex + 1 : 1000,
|
||||
]);
|
||||
|
||||
if (! $exists) {
|
||||
$country->save();
|
||||
$summary['inserted']++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($country->isDirty()) {
|
||||
$country->save();
|
||||
$summary['updated']++;
|
||||
continue;
|
||||
}
|
||||
|
||||
$summary['skipped']++;
|
||||
}
|
||||
|
||||
if ($deactivateMissing ?? (bool) config('skinbase-countries.deactivate_missing', false)) {
|
||||
$summary['deactivated'] = Country::query()
|
||||
->where('active', true)
|
||||
->whereNotIn('iso2', array_keys($seenIso2))
|
||||
->update(['active' => false]);
|
||||
}
|
||||
});
|
||||
|
||||
$summary['backfilled_users'] = $this->backfillUsersFromLegacyProfileCodes();
|
||||
$this->catalog->flushCache();
|
||||
|
||||
return $summary;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
private function loadFallbackRecords(): array
|
||||
{
|
||||
$path = (string) config('skinbase-countries.fallback_seed_path', database_path('data/countries-fallback.json'));
|
||||
|
||||
if (! is_file($path)) {
|
||||
throw new RuntimeException('Country fallback dataset is missing.');
|
||||
}
|
||||
|
||||
try {
|
||||
$decoded = json_decode((string) file_get_contents($path), true, 512, JSON_THROW_ON_ERROR);
|
||||
} catch (JsonException $exception) {
|
||||
throw new RuntimeException('Country fallback dataset is invalid JSON.', previous: $exception);
|
||||
}
|
||||
|
||||
if (! is_array($decoded)) {
|
||||
throw new RuntimeException('Country fallback dataset is not a JSON array.');
|
||||
}
|
||||
|
||||
return $this->remoteProvider->normalizePayload($decoded);
|
||||
}
|
||||
|
||||
private function backfillUsersFromLegacyProfileCodes(): int
|
||||
{
|
||||
if (! Schema::hasTable('user_profiles') || ! Schema::hasTable('users') || ! Schema::hasColumn('users', 'country_id')) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$rows = DB::table('users as users')
|
||||
->join('user_profiles as profiles', 'profiles.user_id', '=', 'users.id')
|
||||
->join('countries as countries', 'countries.iso2', '=', 'profiles.country_code')
|
||||
->whereNull('users.country_id')
|
||||
->whereNotNull('profiles.country_code')
|
||||
->select(['users.id as user_id', 'countries.id as country_id'])
|
||||
->get();
|
||||
|
||||
foreach ($rows as $row) {
|
||||
DB::table('users')
|
||||
->where('id', (int) $row->user_id)
|
||||
->update(['country_id' => (int) $row->country_id]);
|
||||
}
|
||||
|
||||
return $rows->count();
|
||||
}
|
||||
|
||||
private function continentCode(?string $region): ?string
|
||||
{
|
||||
return Arr::get([
|
||||
'Africa' => 'AF',
|
||||
'Americas' => 'AM',
|
||||
'Asia' => 'AS',
|
||||
'Europe' => 'EU',
|
||||
'Oceania' => 'OC',
|
||||
'Antarctic' => 'AN',
|
||||
], trim((string) $region));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user