194 lines
6.7 KiB
PHP
194 lines
6.7 KiB
PHP
<?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));
|
|
}
|
|
}
|