Initial commit

This commit is contained in:
Ronald Huynen
2026-03-23 21:37:59 +01:00
commit 2547717edb
2193 changed files with 972171 additions and 0 deletions

View File

@@ -0,0 +1,905 @@
<?php
namespace App\Http\Livewire\Posts;
use App\Http\Livewire\Traits\RequiresAdminAuthorization;
use App\Models\Category;
use App\Models\Locations\CityLocale;
use App\Models\Locations\CountryLocale;
use App\Models\Locations\DistrictLocale;
use App\Models\Locations\DivisionLocale;
use App\Models\Locations\Location;
use App\Models\Meeting;
use App\Models\Post;
use App\Models\PostTranslation;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\File;
use Laravel\Scout\Jobs\MakeSearchable;
use Livewire\Component;
use WireUi\Traits\WireUiActions;
use ZipArchive;
class BackupRestore extends Component
{
use WireUiActions;
use RequiresAdminAuthorization;
public bool $showRestoreModal = false;
// Chunked upload state
public ?string $uploadId = null;
public ?string $uploadedFilePath = null;
public ?string $selectedFileName = null;
// Optional: show "Backup selected" button (requires parent component to provide selection)
public bool $showBackupSelected = false;
public array $selectedTranslationIds = [];
// Restore state
public array $restorePreview = [];
public array $restorePostList = []; // Lightweight summaries for selection display
public array $selectedPostIndices = []; // Selected post indices from backup
public bool $selectAllPosts = true;
public bool $isRestoring = false;
public string $duplicateAction = 'skip'; // skip, overwrite
public array $restoreStats = [];
protected $listeners = [
'refreshComponent' => '$refresh',
'updateSelectedTranslationIds' => 'updateSelectedTranslationIds',
];
public function mount(bool $showBackupSelected = false)
{
// Verify admin access
$this->authorizeAdminAccess();
$this->showBackupSelected = $showBackupSelected;
}
/**
* Update selected translation IDs from parent component.
*/
public function updateSelectedTranslationIds(array $ids)
{
$this->selectedTranslationIds = $ids;
}
/**
* Toggle select/deselect all posts for restore.
*/
public function toggleSelectAll()
{
if ($this->selectAllPosts) {
$this->selectedPostIndices = array_column($this->restorePostList, 'index');
} else {
$this->selectedPostIndices = [];
}
}
/**
* Update selectAllPosts state when individual checkboxes change.
*/
public function updatedSelectedPostIndices()
{
$this->selectAllPosts = count($this->selectedPostIndices) === count($this->restorePostList);
}
/**
* Generate and download backup for all posts.
*/
public function backup()
{
$this->authorizeAdminAccess();
// Pass query builder (not ->get()) so generateBackup can chunk it
$posts = Post::query();
return $this->generateBackup($posts, 'posts_backup_');
}
/**
* Generate and download backup for selected posts only.
*/
public function backupSelected()
{
$this->authorizeAdminAccess();
if (empty($this->selectedTranslationIds)) {
$this->notification()->error(
__('Error'),
__('No posts selected')
);
return;
}
// Get unique post IDs from selected translation IDs
$postIds = PostTranslation::whereIn('id', $this->selectedTranslationIds)
->pluck('post_id')
->unique()
->toArray();
// Pass query builder (not ->get()) so generateBackup can chunk it
$posts = Post::whereIn('id', $postIds);
return $this->generateBackup($posts, 'posts_selected_backup_');
}
/**
* Generate backup data and return as download response (ZIP archive with media).
*
* @param \Illuminate\Database\Eloquent\Builder|\Illuminate\Database\Eloquent\Collection $posts Query builder or collection
*/
private function generateBackup($posts, string $filenamePrefix)
{
// Build category type lookup (id => type)
$categoryTypes = Category::pluck('type', 'id')->toArray();
$filename = $filenamePrefix . now()->format('Ymd_His') . '.zip';
// Create temporary files
$tempDir = storage_path('app/temp');
if (!File::isDirectory($tempDir)) {
File::makeDirectory($tempDir, 0755, true);
}
$tempPath = $tempDir . '/' . uniqid('backup_') . '.zip';
$tempJsonPath = $tempDir . '/' . uniqid('backup_json_') . '.json';
// Track counts for meta
$counts = ['posts' => 0, 'post_translations' => 0, 'meetings' => 0, 'media_files' => 0];
// Track media files to include in ZIP
$mediaFiles = [];
// Write posts as JSON incrementally to a temp file to avoid holding everything in memory
$jsonHandle = fopen($tempJsonPath, 'w');
// Write a placeholder for meta - will be replaced via a separate meta file in the ZIP
fwrite($jsonHandle, '{"meta":"__PLACEHOLDER__","posts":[');
$isFirst = true;
$chunkSize = 100;
// Process posts in chunks to limit memory usage
$processPost = function ($post) use ($categoryTypes, $jsonHandle, &$isFirst, &$counts, &$mediaFiles) {
$categoryType = $categoryTypes[$post->category_id] ?? null;
$postData = [
'category_type' => $categoryType,
'love_reactant_id' => $post->love_reactant_id,
'author_id' => $post->author_id,
'author_model' => $post->author_model,
'created_at' => $this->formatDate($post->created_at),
'updated_at' => $this->formatDate($post->updated_at),
'translations' => [],
'meeting' => null,
'media' => null,
];
foreach ($post->translations as $translation) {
$postData['translations'][] = [
'locale' => $translation->locale,
'slug' => $translation->slug,
'title' => $translation->title,
'excerpt' => $translation->excerpt,
'content' => $translation->content,
'status' => $translation->status,
'updated_by_user_id' => $translation->updated_by_user_id,
'from' => $this->formatDate($translation->from),
'till' => $this->formatDate($translation->till),
'created_at' => $this->formatDate($translation->created_at),
'updated_at' => $this->formatDate($translation->updated_at),
];
$counts['post_translations']++;
}
if ($post->meeting) {
$meeting = $post->meeting;
$postData['meeting'] = [
'meetingable_type' => $meeting->meetingable_type,
'meetingable_name' => $meeting->meetingable?->name,
'venue' => $meeting->venue,
'address' => $meeting->address,
'price' => $meeting->price,
'based_on_quantity' => $meeting->based_on_quantity,
'transaction_type_id' => $meeting->transaction_type_id,
'status' => $meeting->status,
'from' => $this->formatDate($meeting->from),
'till' => $this->formatDate($meeting->till),
'created_at' => $this->formatDate($meeting->created_at),
'updated_at' => $this->formatDate($meeting->updated_at),
'location' => $this->getLocationNames($meeting->location),
];
$counts['meetings']++;
}
$media = $post->getFirstMedia('posts');
if ($media) {
$originalPath = $media->getPath();
if (File::exists($originalPath)) {
$archivePath = "media/{$post->id}/{$media->file_name}";
$postData['media'] = [
'name' => $media->name,
'file_name' => $media->file_name,
'mime_type' => $media->mime_type,
'size' => $media->size,
'collection_name' => $media->collection_name,
'custom_properties' => $media->custom_properties,
'archive_path' => $archivePath,
];
$mediaFiles[] = [
'source' => $originalPath,
'archive_path' => $archivePath,
];
$counts['media_files']++;
}
}
if (!$isFirst) {
fwrite($jsonHandle, ',');
}
fwrite($jsonHandle, json_encode($postData, JSON_UNESCAPED_UNICODE));
$isFirst = false;
$counts['posts']++;
};
// Use chunking for query builders, iterate for collections
if ($posts instanceof \Illuminate\Database\Eloquent\Builder) {
$posts->with(['translations', 'meeting.location', 'media'])
->chunk($chunkSize, function ($chunk) use ($processPost) {
foreach ($chunk as $post) {
$processPost($post);
}
});
} else {
foreach ($posts as $post) {
$processPost($post);
}
}
// Close JSON array
fwrite($jsonHandle, ']}');
fclose($jsonHandle);
// Build meta JSON
$meta = json_encode([
'version' => '2.0',
'created_at' => now()->toIso8601String(),
'source_database' => config('database.connections.mysql.database'),
'includes_media' => true,
'counts' => $counts,
], JSON_UNESCAPED_UNICODE);
// Replace the placeholder in the temp JSON file without reading it all into memory
// Use a second temp file and stream-copy with the replacement
$finalJsonPath = $tempJsonPath . '.final';
$inHandle = fopen($tempJsonPath, 'r');
$outHandle = fopen($finalJsonPath, 'w');
// Read the placeholder prefix, replace it, then stream the rest
$prefix = fread($inHandle, strlen('{"meta":"__PLACEHOLDER__"'));
fwrite($outHandle, '{"meta":' . $meta);
// Stream the rest of the file in small chunks
while (!feof($inHandle)) {
fwrite($outHandle, fread($inHandle, 8192));
}
fclose($inHandle);
fclose($outHandle);
@unlink($tempJsonPath);
rename($finalJsonPath, $tempJsonPath);
// Create ZIP archive
$zip = new ZipArchive();
if ($zip->open($tempPath, ZipArchive::CREATE | ZipArchive::OVERWRITE) !== true) {
@unlink($tempJsonPath);
$this->notification()->error(
__('Error'),
__('Failed to create ZIP archive')
);
return;
}
$zip->addFile($tempJsonPath, 'backup.json');
foreach ($mediaFiles as $mediaFile) {
if (File::exists($mediaFile['source'])) {
$zip->addFile($mediaFile['source'], $mediaFile['archive_path']);
}
}
$zip->close();
@unlink($tempJsonPath);
// Move ZIP to storage for download via dedicated route (bypasses Livewire response buffering)
$backupsDir = storage_path('app/backups');
if (!File::isDirectory($backupsDir)) {
File::makeDirectory($backupsDir, 0755, true);
}
File::move($tempPath, $backupsDir . '/' . $filename);
$this->notification()->success(
__('Backup created'),
__('Downloaded :count posts with :media media files', [
'count' => $counts['posts'],
'media' => $counts['media_files'],
])
);
// Dispatch browser event to trigger download via dedicated HTTP route
$this->dispatch('backup-ready', filename: $filename);
}
/**
* Open the restore modal.
*/
public function openRestoreModal()
{
$this->authorizeAdminAccess();
$this->cleanupTempFile();
$this->reset(['restorePreview', 'restoreStats', 'duplicateAction', 'restorePostList', 'selectedPostIndices', 'selectAllPosts', 'uploadId', 'uploadedFilePath', 'selectedFileName']);
$this->showRestoreModal = true;
}
/**
* Receive lightweight preview data parsed client-side by JavaScript.
* JS extracts meta + post summaries from the backup JSON so we don't
* need to send the entire multi-MB JSON string over the wire.
*
* @param array $meta The backup meta object
* @param array $postSummaries Array of {index, title, slug, locales, slugs, has_meeting, has_media, category_type}
* @param string $fileName Original file name
* @param bool $isZip Whether the file is a ZIP archive
*/
public function parseBackupPreview(array $meta, array $postSummaries, string $fileName, bool $isZip)
{
$this->selectedFileName = $fileName;
try {
// Collect all slugs for duplicate checking
$allSlugs = [];
foreach ($postSummaries as $summary) {
foreach ($summary['slugs'] ?? [] as $slug) {
$allSlugs[] = $slug;
}
}
$existingSlugs = PostTranslation::withTrashed()
->whereIn('slug', $allSlugs)
->pluck('slug')
->toArray();
$this->restorePreview = [
'version' => $meta['version'] ?? 'unknown',
'created_at' => $meta['created_at'] ?? 'unknown',
'source_database' => $meta['source_database'] ?? 'unknown',
'posts' => $meta['counts']['posts'] ?? count($postSummaries),
'translations' => $meta['counts']['post_translations'] ?? 0,
'meetings' => $meta['counts']['meetings'] ?? 0,
'media_files' => $meta['counts']['media_files'] ?? 0,
'includes_media' => $meta['includes_media'] ?? false,
'duplicates' => count($existingSlugs),
'duplicate_slugs' => array_slice($existingSlugs, 0, 10),
'is_zip' => $isZip,
];
// Store the post summaries for selection UI (already lightweight)
$this->restorePostList = $postSummaries;
$this->selectedPostIndices = array_column($postSummaries, 'index');
$this->selectAllPosts = true;
} catch (\Exception $e) {
$this->addError('restoreFile', __('Error reading file: ') . $e->getMessage());
}
}
/**
* Set the uploaded file path from a completed chunked upload.
* Called by JS after chunk upload finalization succeeds.
*/
public function setUploadedFilePath(string $uploadId)
{
$this->authorizeAdminAccess();
$sessionKey = "backup_restore_file_{$uploadId}";
$path = session($sessionKey);
if (!$path || !File::exists($path)) {
$this->notification()->error(__('Error'), __('Uploaded file not found'));
return;
}
$this->uploadId = $uploadId;
$this->uploadedFilePath = $path;
// Clean up session key
session()->forget($sessionKey);
}
/**
* Execute the restore operation.
*/
public function restore()
{
$this->authorizeAdminAccess();
if (!$this->uploadedFilePath || !File::exists($this->uploadedFilePath)) {
$this->notification()->error(__('Error'), __('No file uploaded. Please upload the file first.'));
return;
}
$this->isRestoring = true;
$extractDir = null;
try {
$filePath = $this->uploadedFilePath;
$extension = strtolower(pathinfo($filePath, PATHINFO_EXTENSION));
$isZip = $extension === 'zip';
$data = null;
if ($isZip) {
// Extract ZIP archive
$extractDir = storage_path('app/temp/restore_' . uniqid());
File::makeDirectory($extractDir, 0755, true);
$zip = new ZipArchive();
if ($zip->open($filePath) !== true) {
$this->notification()->error(__('Error'), __('Failed to open ZIP archive'));
$this->isRestoring = false;
return;
}
// Validate ZIP entries to prevent zip-slip path traversal attacks
for ($i = 0; $i < $zip->numFiles; $i++) {
$entryName = $zip->getNameIndex($i);
$fullPath = realpath($extractDir) . '/' . $entryName;
if (strpos($fullPath, realpath($extractDir)) !== 0) {
$zip->close();
File::deleteDirectory($extractDir);
$this->notification()->error(__('Error'), __('ZIP archive contains unsafe file paths'));
$this->isRestoring = false;
return;
}
}
$zip->extractTo($extractDir);
$zip->close();
$jsonPath = "{$extractDir}/backup.json";
if (!File::exists($jsonPath)) {
File::deleteDirectory($extractDir);
$this->notification()->error(__('Error'), __('Invalid ZIP archive: missing backup.json'));
$this->isRestoring = false;
return;
}
$data = json_decode(File::get($jsonPath), true);
} else {
$data = json_decode(file_get_contents($filePath), true);
}
// Get active profile from session
$profileId = session('activeProfileId');
$profileType = session('activeProfileType');
if (!$profileId || !$profileType) {
if ($extractDir) {
File::deleteDirectory($extractDir);
}
$this->notification()->error(__('Error'), __('No active profile in session'));
$this->isRestoring = false;
return;
}
// Build category type => id lookup
$categoryLookup = Category::pluck('id', 'type')->toArray();
$stats = [
'posts_created' => 0,
'posts_skipped' => 0,
'posts_overwritten' => 0,
'translations_created' => 0,
'meetings_created' => 0,
'media_restored' => 0,
'media_skipped' => 0,
];
$createdPostIds = [];
DB::beginTransaction();
// Filter to only selected posts
$selectedIndices = array_flip($this->selectedPostIndices);
// Disable Scout indexing during bulk restore to prevent timeout
Post::withoutSyncingToSearch(function () use ($data, $profileId, $profileType, $categoryLookup, &$stats, &$createdPostIds, $extractDir, $isZip, $selectedIndices) {
foreach ($data['posts'] as $index => $postData) {
if (!isset($selectedIndices[$index])) {
continue;
}
// Look up category_id by category_type
$categoryId = null;
if (!empty($postData['category_type'])) {
$categoryId = $categoryLookup[$postData['category_type']] ?? null;
}
// Check for existing slugs
if (!empty($postData['translations'])) {
$existingSlugs = PostTranslation::withTrashed()
->whereIn('slug', array_column($postData['translations'], 'slug'))
->pluck('slug')
->toArray();
if (!empty($existingSlugs)) {
if ($this->duplicateAction === 'skip') {
$stats['posts_skipped']++;
continue;
} elseif ($this->duplicateAction === 'overwrite') {
// Delete existing translations and their posts
$existingTranslations = PostTranslation::withTrashed()
->whereIn('slug', $existingSlugs)
->get();
foreach ($existingTranslations as $existingTranslation) {
$postId = $existingTranslation->post_id;
$existingTranslation->forceDelete();
$remainingTranslations = PostTranslation::withTrashed()
->where('post_id', $postId)
->count();
if ($remainingTranslations === 0) {
$existingPost = Post::withTrashed()->find($postId);
if ($existingPost) {
// Clear media before deleting post
$existingPost->clearMediaCollection('posts');
Meeting::withTrashed()->where('post_id', $postId)->forceDelete();
$existingPost->forceDelete();
}
}
}
$stats['posts_overwritten']++;
}
}
}
// Create post
$post = new Post();
$post->postable_id = $profileId;
$post->postable_type = $profileType;
$post->category_id = $categoryId;
// Don't set love_reactant_id - let PostObserver register it as reactant
$post->author_id = null; // Author IDs are not portable between databases
$post->author_model = null;
$post->created_at = $postData['created_at'] ? new \DateTime($postData['created_at']) : now();
$post->updated_at = $postData['updated_at'] ? new \DateTime($postData['updated_at']) : now();
$post->save();
// Ensure post is registered as reactant (in case observer didn't fire)
if (!$post->isRegisteredAsLoveReactant()) {
$post->registerAsLoveReactant();
}
// Create translations
foreach ($postData['translations'] as $translationData) {
$translation = new PostTranslation();
$translation->post_id = $post->id;
$translation->locale = $translationData['locale'];
$translation->slug = $translationData['slug'];
$translation->title = $translationData['title'];
$translation->excerpt = $translationData['excerpt'];
$translation->content = $translationData['content'];
$translation->status = $translationData['status'];
$translation->updated_by_user_id = $translationData['updated_by_user_id'];
$translation->from = $translationData['from'] ? new \DateTime($translationData['from']) : null;
$translation->till = $translationData['till'] ? new \DateTime($translationData['till']) : null;
$translation->created_at = $translationData['created_at'] ? new \DateTime($translationData['created_at']) : now();
$translation->updated_at = $translationData['updated_at'] ? new \DateTime($translationData['updated_at']) : now();
$translation->save();
$stats['translations_created']++;
}
// Create meeting (hasOne relationship)
if (!empty($postData['meeting'])) {
$meetingData = $postData['meeting'];
// Look up meetingable by name and type
$meetingableId = null;
$meetingableType = null;
// Whitelist of allowed meetingable types to prevent arbitrary class instantiation
$allowedMeetingableTypes = [
\App\Models\User::class,
\App\Models\Organization::class,
\App\Models\Bank::class,
];
if (!empty($meetingData['meetingable_type']) && !empty($meetingData['meetingable_name'])) {
$meetingableType = $meetingData['meetingable_type'];
if (in_array($meetingableType, $allowedMeetingableTypes, true)) {
$meetingable = $meetingableType::where('name', $meetingData['meetingable_name'])->first();
if ($meetingable) {
$meetingableId = $meetingable->id;
}
}
}
$meeting = new Meeting();
$meeting->post_id = $post->id;
$meeting->meetingable_id = $meetingableId;
$meeting->meetingable_type = $meetingableId ? $meetingableType : null;
$meeting->venue = $meetingData['venue'];
$meeting->address = $meetingData['address'];
$meeting->price = $meetingData['price'];
$meeting->based_on_quantity = $meetingData['based_on_quantity'];
$meeting->transaction_type_id = $meetingData['transaction_type_id'];
$meeting->status = $meetingData['status'];
$meeting->from = $meetingData['from'] ? new \DateTime($meetingData['from']) : null;
$meeting->till = $meetingData['till'] ? new \DateTime($meetingData['till']) : null;
$meeting->created_at = $meetingData['created_at'] ? new \DateTime($meetingData['created_at']) : now();
$meeting->updated_at = $meetingData['updated_at'] ? new \DateTime($meetingData['updated_at']) : now();
$meeting->save();
// Create location if location data exists
if (!empty($meetingData['location'])) {
$locationIds = $this->lookupLocationIds($meetingData['location']);
if ($locationIds['country_id'] || $locationIds['division_id'] || $locationIds['city_id'] || $locationIds['district_id']) {
$location = new Location();
$location->locatable_id = $meeting->id;
$location->locatable_type = Meeting::class;
$location->country_id = $locationIds['country_id'];
$location->division_id = $locationIds['division_id'];
$location->city_id = $locationIds['city_id'];
$location->district_id = $locationIds['district_id'];
$location->save();
}
}
$stats['meetings_created']++;
}
// Restore media if available
if ($isZip && $extractDir && !empty($postData['media'])) {
$mediaData = $postData['media'];
$mediaPath = "{$extractDir}/{$mediaData['archive_path']}";
// Prevent path traversal via crafted archive_path in JSON
$realMediaPath = realpath($mediaPath);
$realExtractDir = realpath($extractDir);
if ($realMediaPath && $realExtractDir && strpos($realMediaPath, $realExtractDir) === 0 && File::exists($mediaPath)) {
try {
$media = $post->addMedia($mediaPath)
->preservingOriginal() // Don't delete from extract dir yet
->usingName($mediaData['name'])
->usingFileName($mediaData['file_name'])
->withCustomProperties($mediaData['custom_properties'] ?? [])
->toMediaCollection('posts');
// Dispatch conversion job to queue
$conversionCollection = \Spatie\MediaLibrary\Conversions\ConversionCollection::createForMedia($media);
if ($conversionCollection->isNotEmpty()) {
dispatch(new \Spatie\MediaLibrary\Conversions\Jobs\PerformConversionsJob($conversionCollection, $media, false))
->onQueue('low');
}
$stats['media_restored']++;
} catch (\Exception $e) {
$stats['media_skipped']++;
}
} else {
$stats['media_skipped']++;
}
}
$createdPostIds[] = $post->id;
$stats['posts_created']++;
}
}); // End withoutSyncingToSearch
DB::commit();
// Clean up extracted files
if ($extractDir) {
File::deleteDirectory($extractDir);
}
// Queue posts for search indexing on 'low' queue in chunks
if (!empty($createdPostIds)) {
foreach (array_chunk($createdPostIds, 50) as $chunk) {
$posts = Post::whereIn('id', $chunk)->get();
dispatch(new MakeSearchable($posts))->onQueue('low');
}
}
$this->restoreStats = $stats;
// Clean up uploaded temp file and free memory
if ($this->uploadedFilePath && File::exists($this->uploadedFilePath)) {
@unlink($this->uploadedFilePath);
}
$this->uploadedFilePath = null;
$this->uploadId = null;
$this->restorePreview = [];
$this->restorePostList = [];
$this->selectedPostIndices = [];
$mediaMsg = $stats['media_restored'] > 0 ? " with {$stats['media_restored']} media files" : '';
$this->notification()->success(
__('Restore completed'),
__('Created :count posts', ['count' => $stats['posts_created']]) . $mediaMsg
);
// Refresh parent posts table
$this->dispatch('refreshPostsTable')->to('posts.manage');
} catch (\Exception $e) {
DB::rollBack();
if ($extractDir && File::isDirectory($extractDir)) {
File::deleteDirectory($extractDir);
}
$this->notification()->error(__('Error'), $e->getMessage());
}
$this->isRestoring = false;
}
/**
* Close modal and reset state.
*/
public function closeRestoreModal()
{
$this->showRestoreModal = false;
$this->cleanupTempFile();
$this->reset(['restorePreview', 'restoreStats', 'duplicateAction', 'restorePostList', 'selectedPostIndices', 'selectAllPosts', 'uploadId', 'uploadedFilePath', 'selectedFileName']);
}
/**
* Clean up any uploaded temp file.
*/
private function cleanupTempFile(): void
{
if ($this->uploadedFilePath && File::exists($this->uploadedFilePath)) {
@unlink($this->uploadedFilePath);
}
}
/**
* Safely format a date value to ISO8601 string.
*/
private function formatDate($value): ?string
{
if ($value === null) {
return null;
}
if ($value instanceof \Carbon\Carbon || $value instanceof \DateTime) {
return $value->format('c');
}
if (is_string($value)) {
return $value;
}
return null;
}
/**
* Get location names in the app's base locale for backup.
* Names are used for lookup on restore instead of IDs.
*/
private function getLocationNames($location): ?array
{
if (!$location) {
return null;
}
$baseLocale = config('app.locale');
// Get country name
$countryName = null;
if ($location->country_id) {
$countryLocale = CountryLocale::withoutGlobalScopes()
->where('country_id', $location->country_id)
->where('locale', $baseLocale)
->first();
$countryName = $countryLocale?->name;
}
// Get division name
$divisionName = null;
if ($location->division_id) {
$divisionLocale = DivisionLocale::withoutGlobalScopes()
->where('division_id', $location->division_id)
->where('locale', $baseLocale)
->first();
$divisionName = $divisionLocale?->name;
}
// Get city name
$cityName = null;
if ($location->city_id) {
$cityLocale = CityLocale::withoutGlobalScopes()
->where('city_id', $location->city_id)
->where('locale', $baseLocale)
->first();
$cityName = $cityLocale?->name;
}
// Get district name
$districtName = null;
if ($location->district_id) {
$districtLocale = DistrictLocale::withoutGlobalScopes()
->where('district_id', $location->district_id)
->where('locale', $baseLocale)
->first();
$districtName = $districtLocale?->name;
}
return [
'country_name' => $countryName,
'division_name' => $divisionName,
'city_name' => $cityName,
'district_name' => $districtName,
];
}
/**
* Look up location IDs by names in the app's base locale.
* Returns null for any location component that cannot be found.
*/
private function lookupLocationIds(array $locationData): array
{
$baseLocale = config('app.locale');
$result = [
'country_id' => null,
'division_id' => null,
'city_id' => null,
'district_id' => null,
];
// Look up country by name
if (!empty($locationData['country_name'])) {
$countryLocale = CountryLocale::withoutGlobalScopes()
->where('name', $locationData['country_name'])
->where('locale', $baseLocale)
->first();
$result['country_id'] = $countryLocale?->country_id;
}
// Look up division by name
if (!empty($locationData['division_name'])) {
$divisionLocale = DivisionLocale::withoutGlobalScopes()
->where('name', $locationData['division_name'])
->where('locale', $baseLocale)
->first();
$result['division_id'] = $divisionLocale?->division_id;
}
// Look up city by name
if (!empty($locationData['city_name'])) {
$cityLocale = CityLocale::withoutGlobalScopes()
->where('name', $locationData['city_name'])
->where('locale', $baseLocale)
->first();
$result['city_id'] = $cityLocale?->city_id;
}
// Look up district by name
if (!empty($locationData['district_name'])) {
$districtLocale = DistrictLocale::withoutGlobalScopes()
->where('name', $locationData['district_name'])
->where('locale', $baseLocale)
->first();
$result['district_id'] = $districtLocale?->district_id;
}
return $result;
}
public function render()
{
return view('livewire.posts.backup-restore');
}
}