argument('file'); $dryRun = $this->option('dry-run'); $skipExisting = $this->option('skip-existing'); $skipMedia = $this->option('skip-media'); // Validate file exists if (!File::exists($filePath)) { $this->error("Backup file not found: {$filePath}"); return Command::FAILURE; } // Determine file type and extract if necessary $isZip = strtolower(pathinfo($filePath, PATHINFO_EXTENSION)) === 'zip'; $extractDir = null; $backupData = null; if ($isZip) { // Extract ZIP archive if (!class_exists('ZipArchive')) { $this->error('ZipArchive extension is not available. Install php-zip extension.'); return Command::FAILURE; } $extractDir = storage_path('app/temp/restore_' . uniqid()); File::makeDirectory($extractDir, 0755, true); $zip = new ZipArchive(); if ($zip->open($filePath) !== true) { $this->error("Failed to open ZIP archive: {$filePath}"); File::deleteDirectory($extractDir); return Command::FAILURE; } $zip->extractTo($extractDir); $zip->close(); $this->info("Extracted ZIP archive to temporary directory"); // Read backup.json from extracted directory $jsonPath = "{$extractDir}/backup.json"; if (!File::exists($jsonPath)) { $this->error("Invalid ZIP archive: missing backup.json"); File::deleteDirectory($extractDir); return Command::FAILURE; } $json = File::get($jsonPath); } else { // Read JSON file directly $json = File::get($filePath); } $backupData = json_decode($json, true); if (json_last_error() !== JSON_ERROR_NONE) { $this->error("Invalid JSON file: " . json_last_error_msg()); if ($extractDir) { File::deleteDirectory($extractDir); } return Command::FAILURE; } // Validate backup format if (!isset($backupData['meta']) || !isset($backupData['posts'])) { $this->error("Invalid backup file format. Missing 'meta' or 'posts' keys."); if ($extractDir) { File::deleteDirectory($extractDir); } return Command::FAILURE; } $includesMedia = $backupData['meta']['includes_media'] ?? false; $mediaCount = $backupData['meta']['counts']['media_files'] ?? 0; $this->info("Backup file info:"); $this->table( ['Property', 'Value'], [ ['Version', $backupData['meta']['version'] ?? 'unknown'], ['Created', $backupData['meta']['created_at'] ?? 'unknown'], ['Source Database', $backupData['meta']['source_database'] ?? 'unknown'], ['Posts', $backupData['meta']['counts']['posts'] ?? count($backupData['posts'])], ['Translations', $backupData['meta']['counts']['post_translations'] ?? 'unknown'], ['Meetings', $backupData['meta']['counts']['meetings'] ?? 'unknown'], ['Media Files', $mediaCount], ['Includes Media', $includesMedia ? 'Yes' : 'No'], ] ); if ($includesMedia && $skipMedia) { $this->warn("Media restoration will be skipped (--skip-media flag)"); } // Handle post selection with --select flag $postsToRestore = $backupData['posts']; if ($this->option('select')) { $baseLocale = config('app.locale'); // Build numbered list for display $tableRows = []; foreach ($backupData['posts'] as $index => $post) { $title = null; $locales = []; foreach ($post['translations'] ?? [] as $translation) { $locales[] = $translation['locale']; if ($translation['locale'] === $baseLocale) { $title = $translation['title']; } } if ($title === null && !empty($post['translations'])) { $title = $post['translations'][0]['title']; } $indicators = []; if (!empty($post['meeting'])) $indicators[] = 'meeting'; if (!empty($post['media'])) $indicators[] = 'media'; $indicatorStr = $indicators ? ' [' . implode(', ', $indicators) . ']' : ''; $tableRows[] = [ $index + 1, ($title ?? 'Untitled') . $indicatorStr, implode(', ', $locales), ]; } $this->newLine(); $this->info('Available posts:'); $this->table(['#', 'Title', 'Locales'], $tableRows); $this->info("Enter post numbers to restore (comma-separated, ranges with dash, or 'all')."); $this->info("Examples: 1,3,5 or 1-10 or 1-5,8,12-15 or all"); $input = $this->ask('Selection'); if (strtolower(trim($input)) !== 'all') { $selectedIndices = $this->parseSelection($input, count($backupData['posts'])); if (empty($selectedIndices)) { $this->error('No valid posts selected.'); if ($extractDir) { File::deleteDirectory($extractDir); } return Command::FAILURE; } $postsToRestore = []; foreach ($selectedIndices as $idx) { $postsToRestore[] = $backupData['posts'][$idx]; } $this->info("Selected " . count($postsToRestore) . " of " . count($backupData['posts']) . " posts."); } } // Determine profile for post ownership $profileId = $this->option('profile-id'); $profileType = $this->option('profile-type'); if ($profileId && $profileType) { // Use provided profile $profileType = $this->resolveProfileType($profileType); if (!$profileType) { $this->error("Invalid profile type. Use: User, Organization, Bank, or Admin"); return Command::FAILURE; } } else { // Try to get from session (won't work in CLI, but check anyway) $profileId = session('activeProfileId'); $profileType = session('activeProfileType'); if (!$profileId || !$profileType) { $this->error("No active profile in session. Please provide --profile-id and --profile-type options."); $this->info("Example: php artisan posts:restore backup.json --profile-id=1 --profile-type=User"); return Command::FAILURE; } } // Validate profile exists if (!class_exists($profileType)) { $this->error("Profile type class not found: {$profileType}"); if ($extractDir) { File::deleteDirectory($extractDir); } return Command::FAILURE; } $profile = $profileType::find($profileId); if (!$profile) { $this->error("Profile not found: {$profileType} with ID {$profileId}"); if ($extractDir) { File::deleteDirectory($extractDir); } return Command::FAILURE; } $this->info("Posts will be assigned to: {$profile->name} ({$profileType} #{$profileId})"); // Build category type => id lookup for the target database $categoryLookup = Category::pluck('id', 'type')->toArray(); if ($dryRun) { $this->warn("DRY RUN MODE - No changes will be made"); } if (!$dryRun && !$this->confirm("Do you want to proceed with the restore?")) { $this->info("Restore cancelled."); if ($extractDir) { File::deleteDirectory($extractDir); } return Command::SUCCESS; } $stats = [ 'posts_created' => 0, 'posts_skipped' => 0, 'posts_overwritten' => 0, 'translations_created' => 0, 'meetings_created' => 0, 'media_restored' => 0, 'media_skipped' => 0, 'category_not_found' => 0, ]; // Track "all" choices for duplicate handling $skipAll = null; $bar = $this->output->createProgressBar(count($postsToRestore)); $bar->start(); DB::beginTransaction(); try { foreach ($postsToRestore as $postData) { // Look up category_id by category_type $categoryId = null; if (!empty($postData['category_type'])) { $categoryId = $categoryLookup[$postData['category_type']] ?? null; if ($categoryId === null) { $this->newLine(); $this->warn("Category type not found: {$postData['category_type']}"); $stats['category_not_found']++; } } // Check for existing slugs if (!empty($postData['translations'])) { $existingSlugs = PostTranslation::withTrashed() ->whereIn('slug', array_column($postData['translations'], 'slug')) ->pluck('slug') ->toArray(); if (!empty($existingSlugs)) { $this->newLine(); $this->warn("Duplicate slug(s) found: " . implode(', ', $existingSlugs)); // Determine action based on flags or prompt $action = $skipAll ?? null; if ($action === null && !$skipExisting) { $action = $this->choice( 'What would you like to do?', [ 'skip' => 'Skip this post', 'overwrite' => 'Overwrite existing post(s)', 'skip_all' => 'Skip all duplicates', 'overwrite_all' => 'Overwrite all duplicates', ], 'skip' ); if ($action === 'skip_all') { $skipAll = 'skip'; $action = 'skip'; } elseif ($action === 'overwrite_all') { $skipAll = 'overwrite'; $action = 'overwrite'; } } elseif ($skipExisting) { $action = 'skip'; } if ($action === 'skip') { $stats['posts_skipped']++; $bar->advance(); continue; } elseif ($action === 'overwrite') { // Delete existing translations and their posts if they become empty $existingTranslations = PostTranslation::withTrashed() ->whereIn('slug', $existingSlugs) ->get(); foreach ($existingTranslations as $existingTranslation) { $postId = $existingTranslation->post_id; $existingTranslation->forceDelete(); // Check if post has no more translations and delete it too $remainingTranslations = PostTranslation::withTrashed() ->where('post_id', $postId) ->count(); if ($remainingTranslations === 0) { $existingPost = Post::withTrashed()->find($postId); if ($existingPost) { // Delete related meetings first Meeting::withTrashed()->where('post_id', $postId)->forceDelete(); $existingPost->forceDelete(); } } } $stats['posts_overwritten']++; } } } if (!$dryRun) { // Create post with profile ownership $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 = $postData['author_id']; $post->author_model = $postData['author_model']; $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 and not skipped if (!$skipMedia && $extractDir && !empty($postData['media'])) { $mediaData = $postData['media']; $mediaPath = "{$extractDir}/{$mediaData['archive_path']}"; if (File::exists($mediaPath)) { try { $media = $post->addMedia($mediaPath) ->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) { $this->newLine(); $this->warn("Failed to restore media for post {$post->id}: " . $e->getMessage()); $stats['media_skipped']++; } } else { $this->newLine(); $this->warn("Media file not found in archive: {$mediaData['archive_path']}"); $stats['media_skipped']++; } } } else { // Dry run - just count $stats['translations_created'] += count($postData['translations']); $stats['meetings_created'] += !empty($postData['meeting']) ? 1 : 0; if (!empty($postData['media'])) { $stats['media_restored']++; } } $stats['posts_created']++; $bar->advance(); } if (!$dryRun) { DB::commit(); } } catch (\Exception $e) { DB::rollBack(); $this->newLine(); $this->error("Restore failed: " . $e->getMessage()); if ($extractDir) { File::deleteDirectory($extractDir); } return Command::FAILURE; } // Clean up extracted files if ($extractDir) { File::deleteDirectory($extractDir); $this->info("Cleaned up temporary files"); } $bar->finish(); $this->newLine(2); $this->info($dryRun ? "Dry run completed!" : "Restore completed successfully!"); $this->table( ['Metric', 'Value'], [ ['Posts Created', $stats['posts_created']], ['Posts Skipped', $stats['posts_skipped']], ['Posts Overwritten', $stats['posts_overwritten']], ['Translations Created', $stats['translations_created']], ['Meetings Created', $stats['meetings_created']], ['Media Restored', $stats['media_restored']], ['Media Skipped', $stats['media_skipped']], ['Categories Not Found', $stats['category_not_found']], ] ); if ($stats['category_not_found'] > 0) { $this->warn("Some posts were created without a category. You may need to assign categories manually."); } return Command::SUCCESS; } /** * Resolve profile type string to full class name. */ private function resolveProfileType(string $type): ?string { $typeMap = [ 'user' => \App\Models\User::class, 'organization' => \App\Models\Organization::class, 'bank' => \App\Models\Bank::class, 'admin' => \App\Models\Admin::class, ]; $normalized = strtolower(trim($type)); // Handle full class names - only allow known model classes if (str_contains($type, '\\')) { return in_array($type, $typeMap, true) ? $type : null; } return $typeMap[$normalized] ?? null; } /** * 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; } /** * Parse a user selection string like "1,3,5-10" into an array of 0-based indices. */ private function parseSelection(string $input, int $total): array { $indices = []; $parts = preg_split('/\s*,\s*/', trim($input)); foreach ($parts as $part) { $part = trim($part); if (preg_match('/^(\d+)-(\d+)$/', $part, $matches)) { $start = max(1, (int) $matches[1]); $end = min($total, (int) $matches[2]); for ($i = $start; $i <= $end; $i++) { $indices[] = $i - 1; } } elseif (preg_match('/^\d+$/', $part)) { $num = (int) $part; if ($num >= 1 && $num <= $total) { $indices[] = $num - 1; } } } return array_values(array_unique($indices)); } }